]> git.madduck.net Git - etc/neomutt.git/blob - .config/neomutt/buildmimetree.py

madduck's git repository

Every one of the projects in this repository is available at the canonical URL git://git.madduck.net/madduck/pub/<projectpath> — see each project's metadata for the exact URL.

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.

SSH access, as well as push access can be individually arranged.

If you use my repositories frequently, consider adding the following snippet to ~/.gitconfig and using the third clone URL listed for each project:

[url "git://git.madduck.net/madduck/"]
  insteadOf = madduck:

c277e6344477f5fa025eebf36176c22dfeb26046
[etc/neomutt.git] / .config / neomutt / buildmimetree.py
1 #!/usr/bin/python3
2 #
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.
6 #
7 # Configuration:
8 #   neomuttrc (needs to be a single line):
9 #     set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
10 #     macro compose B "\
11 #       <enter-command> source '$my_confdir/buildmimetree.py \
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"
15 #
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
19 #     information)
20 #
21 # Requirements:
22 #   - python3
23 #   - python3-markdown
24 # Optional:
25 #   - pytest
26 #   - Pynliner
27 #   - Pygments, if installed, then syntax highlighting is enabled
28 #
29 # Latest version:
30 #   https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
31 #
32 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
33 # Released under the GPL-2+ licence, just like Mutt itself.
34 #
35
36 import sys
37 import pathlib
38 import markdown
39 import tempfile
40 import argparse
41 import re
42 import mimetypes
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
48
49
50 def parse_cli_args(*args, **kwargs):
51     parser = argparse.ArgumentParser(
52         description=(
53             "NeoMutt helper to turn text/markdown email parts "
54             "into full-fledged MIME trees"
55         )
56     )
57     parser.epilog = (
58         "Copyright © 2023 martin f. krafft <madduck@madduck.net>.\n"
59         "Released under the MIT licence"
60     )
61
62     parser.add_argument(
63         "--extensions",
64         type=str,
65         default="",
66         help="Markdown extension to use (comma-separated list)",
67     )
68
69     parser.add_argument(
70         "--only-build",
71         action="store_true",
72         help="Only build, don't send the message",
73     )
74
75     parser.add_argument(
76         "--tempdir",
77         default=None,
78         help="Specify temporary directory to use for attachments",
79     )
80
81     parser.add_argument(
82         "--debug-commands",
83         action="store_true",
84         help="Turn on debug logging of commands generated to stderr",
85     )
86
87     subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
88     massage_p = subp.add_parser(
89         "massage", help="Massaging phase (internal use)"
90     )
91
92     massage_p.add_argument(
93         "--write-commands-to",
94         metavar="PATH",
95         dest="cmdpath",
96         help="Temporary file path to write commands to",
97     )
98
99     massage_p.add_argument(
100         "--debug-walk",
101         action="store_true",
102         help="Turn on debugging to stderr of the MIME tree walk",
103     )
104
105     massage_p.add_argument(
106         "MAILDRAFT",
107         nargs="?",
108         help="If provided, the script is invoked as editor on the mail draft",
109     )
110
111     return parser.parse_args(*args, **kwargs)
112
113
114 # [ MARKDOWN WRAPPING ] #######################################################
115
116
117 InlineImageInfo = namedtuple(
118     "InlineImageInfo", ["cid", "desc"], defaults=[None]
119 )
120
121
122 class InlineImageExtension(Extension):
123     class RelatedImageInlineProcessor(ImageInlineProcessor):
124         def __init__(self, re, md, ext):
125             super().__init__(re, md)
126             self._ext = ext
127
128         def handleMatch(self, m, data):
129             el, start, end = super().handleMatch(m, data)
130             if "src" in el.attrib:
131                 src = el.attrib["src"]
132                 if "://" not in src or src.startswith("file://"):
133                     # We only inline local content
134                     cid = self._ext.get_cid_for_image(el.attrib)
135                     el.attrib["src"] = f"cid:{cid}"
136             return el, start, end
137
138     def __init__(self):
139         super().__init__()
140         self._images = OrderedDict()
141
142     def extendMarkdown(self, md):
143         md.registerExtension(self)
144         inline_image_proc = self.RelatedImageInlineProcessor(
145             IMAGE_LINK_RE, md, self
146         )
147         md.inlinePatterns.register(inline_image_proc, "image_link", 150)
148
149     def get_cid_for_image(self, attrib):
150         msgid = make_msgid()[1:-1]
151         path = attrib["src"]
152         if path.startswith("/"):
153             path = f"file://{path}"
154         self._images[path] = InlineImageInfo(
155             msgid, attrib.get("title", attrib.get("alt"))
156         )
157         return msgid
158
159     def get_images(self):
160         return self._images
161
162
163 def markdown_with_inline_image_support(text, *, extensions=None):
164     inline_image_handler = InlineImageExtension()
165     extensions = extensions or []
166     extensions.append(inline_image_handler)
167     mdwn = markdown.Markdown(extensions=extensions)
168     htmltext = mdwn.convert(text)
169
170     images = inline_image_handler.get_images()
171
172     def replace_image_with_cid(matchobj):
173         for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
174             if m in images:
175                 return f"(cid:{images[m].cid}"
176         return matchobj.group(0)
177
178     text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
179     return text, htmltext, images
180
181
182 # [ PARTS GENERATION ] ########################################################
183
184
185 class Part(
186     namedtuple(
187         "Part",
188         ["type", "subtype", "path", "desc", "cid", "orig"],
189         defaults=[None, None, False],
190     )
191 ):
192     def __str__(self):
193         ret = f"<{self.type}/{self.subtype}>"
194         if self.cid:
195             ret = f"{ret} cid:{self.cid}"
196         if self.orig:
197             ret = f"{ret} ORIGINAL"
198         return ret
199
200
201 class Multipart(
202     namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
203 ):
204     def __str__(self):
205         return f"<multipart/{self.subtype}> children={len(self.children)}"
206
207
208 def filewriter_fn(path, content, mode="w", **kwargs):
209     with open(path, mode, **kwargs) as out_f:
210         out_f.write(content)
211
212
213 def collect_inline_images(
214     images, *, tempdir=None, filewriter_fn=filewriter_fn
215 ):
216     relparts = []
217     for path, info in images.items():
218         data = request.urlopen(path)
219
220         mimetype = data.headers["Content-Type"]
221         ext = mimetypes.guess_extension(mimetype)
222         tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
223         path = pathlib.Path(tempfilename[1])
224
225         filewriter_fn(path, data.read(), "w+b")
226
227         relparts.append(
228             Part(
229                 *mimetype.split("/"),
230                 path,
231                 cid=info.cid,
232                 desc=f"Image: {info.desc}",
233             )
234         )
235
236     return relparts
237
238
239 def convert_markdown_to_html(
240     origtext,
241     draftpath,
242     *,
243     filewriter_fn=filewriter_fn,
244     tempdir=None,
245     extensions=None,
246 ):
247     origtext, htmltext, images = markdown_with_inline_image_support(
248         origtext, extensions=extensions
249     )
250
251     filewriter_fn(draftpath, origtext, encoding="utf-8")
252     textpart = Part(
253         "text", "plain", draftpath, "Plain-text version", orig=True
254     )
255
256     htmlpath = draftpath.with_suffix(".html")
257     filewriter_fn(
258         htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
259     )
260     htmlpart = Part("text", "html", htmlpath, "HTML version")
261
262     altpart = Multipart(
263         "alternative", [textpart, htmlpart], "Group of alternative content"
264     )
265
266     imgparts = collect_inline_images(
267         images, tempdir=tempdir, filewriter_fn=filewriter_fn
268     )
269     if imgparts:
270         return Multipart(
271             "relative", [altpart] + imgparts, "Group of related content"
272         )
273     else:
274         return altpart
275
276
277 class MIMETreeDFWalker:
278     def __init__(self, *, visitor_fn=None, debug=False):
279         self._visitor_fn = visitor_fn
280         self._debug = debug
281
282     def walk(self, root, *, visitor_fn=None):
283         """
284         Recursive function to implement a depth-dirst walk of the MIME-tree
285         rooted at `root`.
286         """
287
288         if isinstance(root, list):
289             root = Multipart("mixed", children=root)
290
291         self._walk(
292             root,
293             stack=[],
294             visitor_fn=visitor_fn or self._visitor_fn,
295         )
296
297     def _walk(self, node, *, stack, visitor_fn):
298         # Let's start by enumerating the parts at the current level. At the
299         # root level, stack will be the empty list, and we expect a multipart/*
300         # container at this level. Later, e.g. within a mutlipart/alternative
301         # container, the subtree will just be the alternative parts, while the
302         # top of the stack will be the multipart/alternative container, which
303         # we will process after the following loop.
304
305         lead = f"{'| '*len(stack)}|-"
306         if isinstance(node, Multipart):
307             self.debugprint(
308                 f"{lead}{node} parents={[s.subtype for s in stack]}"
309             )
310
311             # Depth-first, so push the current container onto the stack,
312             # then descend …
313             stack.append(node)
314             self.debugprint("| " * (len(stack) + 1))
315             for child in node.children:
316                 self._walk(
317                     child,
318                     stack=stack,
319                     visitor_fn=visitor_fn,
320                 )
321             self.debugprint("| " * len(stack))
322             assert stack.pop() == node
323
324         else:
325             self.debugprint(f"{lead}{node}")
326
327         if visitor_fn:
328             visitor_fn(node, stack, debugprint=self.debugprint)
329
330     def debugprint(self, s, **kwargs):
331         if self._debug:
332             print(s, file=sys.stderr, **kwargs)
333
334
335 # [ RUN MODES ] ###############################################################
336
337
338 class MuttCommands:
339     """
340     Stupid class to interface writing out Mutt commands. This is quite a hack
341     to deal with the fact that Mutt runs "push" commands in reverse order, so
342     all of a sudden, things become very complicated when mixing with "real"
343     commands.
344
345     Hence we keep two sets of commands, and one set of pushes. Commands are
346     added to the first until a push is added, after which commands are added to
347     the second set of commands.
348
349     On flush(), the first set is printed, followed by the pushes in reverse,
350     and then the second set is printed. All 3 sets are then cleared.
351     """
352
353     def __init__(self, out_f=sys.stdout, *, debug=False):
354         self._cmd1, self._push, self._cmd2 = [], [], []
355         self._out_f = out_f
356         self._debug = debug
357
358     def cmd(self, s):
359         self.debugprint(s)
360         if self._push:
361             self._cmd2.append(s)
362         else:
363             self._cmd1.append(s)
364
365     def push(self, s):
366         s = s.replace('"', '"')
367         s = f'push "{s}"'
368         self.debugprint(s)
369         self._push.insert(0, s)
370
371     def flush(self):
372         print(
373             "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
374         )
375         self._cmd1, self._push, self._cmd2 = [], [], []
376
377     def debugprint(self, s, **kwargs):
378         if self._debug:
379             print(s, file=sys.stderr, **kwargs)
380
381
382 def do_setup(
383     *,
384     out_f=sys.stdout,
385     temppath=None,
386     tempdir=None,
387     debug_commands=False,
388 ):
389     temppath = temppath or pathlib.Path(
390         tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
391     )
392     cmds = MuttCommands(out_f, debug=debug_commands)
393
394     editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
395
396     cmds.cmd('set my_editor="$editor"')
397     cmds.cmd('set my_edit_headers="$edit_headers"')
398     cmds.cmd(f'set editor="{editor}"')
399     cmds.cmd("unset edit_headers")
400     cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
401     cmds.push("<first-entry><edit-file>")
402     cmds.flush()
403
404
405 def do_massage(
406     draft_f,
407     draftpath,
408     cmd_f,
409     *,
410     extensions=None,
411     converter=convert_markdown_to_html,
412     only_build=False,
413     tempdir=None,
414     debug_commands=False,
415     debug_walk=False,
416 ):
417     # Here's the big picture: we're being invoked as the editor on the email
418     # draft, and whatever commands we write to the file given as cmdpath will
419     # be run by the second source command in the macro definition.
420
421     # Let's start by cleaning up what the setup did (see above), i.e. we
422     # restore the $editor and $edit_headers variables, and also unset the
423     # variable used to identify the command file we're currently writing
424     # to.
425     cmds = MuttCommands(cmd_f, debug=debug_commands)
426     cmds.cmd('set editor="$my_editor"')
427     cmds.cmd('set edit_headers="$my_edit_headers"')
428     cmds.cmd("unset my_editor")
429     cmds.cmd("unset my_edit_headers")
430
431     # let's flush those commands, as there'll be a lot of pushes from now
432     # on, which need to be run in reverse order
433     cmds.flush()
434
435     extensions = extensions.split(",") if extensions else []
436     tree = converter(
437         draft_f.read(),
438         draftpath,
439         cssfile=cssfile,
440         tempdir=tempdir,
441         extensions=extensions,
442     )
443
444     mimetree = MIMETreeDFWalker(debug=debug_walk)
445
446     def visitor_fn(item, stack, *, debugprint=None):
447         """
448         Visitor function called for every node (part) of the MIME tree,
449         depth-first, and responsible for telling NeoMutt how to assemble
450         the tree.
451         """
452         KILL_LINE = r"\Ca\Ck"
453
454         if isinstance(item, Part):
455             # We've hit a leaf-node, i.e. an alternative or a related part
456             # with actual content.
457
458             # Let's add the part
459             if item.orig:
460                 # The original source already exists in the NeoMutt tree, but
461                 # the underlying file may have been modified, so we need to
462                 # update the encoding, but that's it:
463                 cmds.push("<update-encoding>")
464             else:
465                 # … whereas all other parts need to be added, and they're all
466                 # considered to be temporary and inline:
467                 cmds.push(f"<attach-file>{item.path}<enter>")
468                 cmds.push("<toggle-unlink><toggle-disposition>")
469
470             # If the item (including the original) comes with additional
471             # information, then we might just as well update the NeoMutt
472             # tree now:
473             if item.cid:
474                 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
475
476         elif isinstance(item, Multipart):
477             # This node has children, but we already visited them (see
478             # above), and so they have been tagged in NeoMutt's compose
479             # window. Now it's just a matter of telling NeoMutt to do the
480             # appropriate grouping:
481             if item.subtype == "alternative":
482                 cmds.push("<group-alternatives>")
483             elif item.subtype in ("relative", "related"):
484                 cmds.push("<group-related>")
485             elif item.subtype == "multilingual":
486                 cmds.push("<group-multilingual>")
487
488         else:
489             # We should never get here
490             assert not "is valid part"
491
492         # If the item has a description, we might just as well add it
493         if item.desc:
494             cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
495
496         # Finally, if we're at non-root level, tag the new container,
497         # as it might itself be part of a container, to be processed
498         # one level up:
499         if stack:
500             cmds.push("<tag-entry>")
501
502     # -----------------
503     # End of visitor_fn
504
505     # Let's walk the tree and visit every node with our fancy visitor
506     # function
507     mimetree.walk(tree, visitor_fn=visitor_fn)
508
509     if not only_build:
510         cmds.push("<send-message>")
511
512     # Finally, cleanup. Since we're responsible for removing the temporary
513     # file, how's this for a little hack?
514     try:
515         filename = cmd_f.name
516     except AttributeError:
517         filename = "pytest_internal_file"
518     cmds.cmd(f"source 'rm -f {filename}|'")
519     cmds.cmd("unset my_mdwn_postprocess_cmd_file")
520     cmds.flush()
521
522
523 # [ CLI ENTRY ] ###############################################################
524
525 if __name__ == "__main__":
526     args = parse_cli_args()
527
528     if args.mode is None:
529         do_setup(
530             tempdir=args.tempdir,
531             debug_commands=args.debug_commands,
532         )
533
534     elif args.mode == "massage":
535         with open(args.MAILDRAFT, "r") as draft_f, open(
536             args.cmdpath, "w"
537         ) as cmd_f:
538             do_massage(
539                 draft_f,
540                 pathlib.Path(args.MAILDRAFT),
541                 cmd_f,
542                 extensions=args.extensions,
543                 only_build=args.only_build,
544                 tempdir=args.tempdir,
545                 debug_commands=args.debug_commands,
546                 debug_walk=args.debug_walk,
547             )
548
549
550 # [ TESTS ] ###################################################################
551
552 try:
553     import pytest
554     from io import StringIO
555
556     class Tests:
557         @pytest.fixture
558         def const1(self):
559             return "CONSTANT STRING 1"
560
561         @pytest.fixture
562         def const2(self):
563             return "CONSTANT STRING 2"
564
565         # NOTE: tests using the capsys fixture must specify sys.stdout to the
566         # functions they call, else old stdout is used and not captured
567
568         def test_MuttCommands_cmd(self, const1, const2, capsys):
569             "Assert order of commands"
570             cmds = MuttCommands(out_f=sys.stdout)
571             cmds.cmd(const1)
572             cmds.cmd(const2)
573             cmds.flush()
574             captured = capsys.readouterr()
575             assert captured.out == "\n".join((const1, const2, ""))
576
577         def test_MuttCommands_push(self, const1, const2, capsys):
578             "Assert reverse order of pushes"
579             cmds = MuttCommands(out_f=sys.stdout)
580             cmds.push(const1)
581             cmds.push(const2)
582             cmds.flush()
583             captured = capsys.readouterr()
584             assert (
585                 captured.out
586                 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
587             )
588
589         def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
590             "Assert reverse order of pushes"
591             cmds = MuttCommands(out_f=sys.stdout)
592             lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
593             for i in range(2):
594                 cmds.cmd(lines[4 * i + 0])
595                 cmds.cmd(lines[4 * i + 1])
596                 cmds.push(lines[4 * i + 2])
597                 cmds.push(lines[4 * i + 3])
598             cmds.flush()
599
600             captured = capsys.readouterr()
601             lines_out = captured.out.splitlines()
602             assert lines[0] in lines_out[0]
603             assert lines[1] in lines_out[1]
604             assert lines[7] in lines_out[2]
605             assert lines[6] in lines_out[3]
606             assert lines[3] in lines_out[4]
607             assert lines[2] in lines_out[5]
608             assert lines[4] in lines_out[6]
609             assert lines[5] in lines_out[7]
610
611         @pytest.fixture
612         def basic_mime_tree(self):
613             return Multipart(
614                 "relative",
615                 children=[
616                     Multipart(
617                         "alternative",
618                         children=[
619                             Part(
620                                 "text",
621                                 "plain",
622                                 "part.txt",
623                                 desc="Plain",
624                                 orig=True,
625                             ),
626                             Part("text", "html", "part.html", desc="HTML"),
627                         ],
628                         desc="Alternative",
629                     ),
630                     Part(
631                         "text", "png", "logo.png", cid="logo.png", desc="Logo"
632                     ),
633                 ],
634                 desc="Related",
635             )
636
637         def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
638             mimetree = MIMETreeDFWalker()
639
640             items = []
641
642             def visitor_fn(item, stack, debugprint):
643                 items.append((item, len(stack)))
644
645             mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
646             assert len(items) == 5
647             assert items[0][0].subtype == "plain"
648             assert items[0][1] == 2
649             assert items[1][0].subtype == "html"
650             assert items[1][1] == 2
651             assert items[2][0].subtype == "alternative"
652             assert items[2][1] == 1
653             assert items[3][0].subtype == "png"
654             assert items[3][1] == 1
655             assert items[4][0].subtype == "relative"
656             assert items[4][1] == 0
657
658         def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
659             mimetree = MIMETreeDFWalker()
660             items = []
661
662             def visitor_fn(item, stack, debugprint):
663                 items.append(item)
664
665             mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
666             assert items[-1].subtype == "mixed"
667
668         def test_MIMETreeDFWalker_visitor_in_constructor(
669             self, basic_mime_tree
670         ):
671             items = []
672
673             def visitor_fn(item, stack, debugprint):
674                 items.append(item)
675
676             mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
677             mimetree.walk(basic_mime_tree)
678             assert len(items) == 5
679
680         @pytest.fixture
681         def string_io(self, const1, text=None):
682             return StringIO(text or const1)
683
684         def test_do_massage_basic(self, const1, string_io, capsys):
685             def converter(drafttext, draftpath, extensions, tempdir):
686                 return Part("text", "plain", draftpath, orig=True)
687
688             do_massage(
689                 draft_f=string_io,
690                 draftpath=const1,
691                 cmd_f=sys.stdout,
692                 converter=converter,
693             )
694
695             captured = capsys.readouterr()
696             lines = captured.out.splitlines()
697             assert '="$my_editor"' in lines.pop(0)
698             assert '="$my_edit_headers"' in lines.pop(0)
699             assert "unset my_editor" == lines.pop(0)
700             assert "unset my_edit_headers" == lines.pop(0)
701             assert "send-message" in lines.pop(0)
702             assert "update-encoding" in lines.pop(0)
703             assert "source 'rm -f " in lines.pop(0)
704             assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
705
706         def test_do_massage_fulltree(
707             self, string_io, const1, basic_mime_tree, capsys
708         ):
709             def converter(drafttext, draftpath, extensions, tempdir):
710                 return basic_mime_tree
711
712             do_massage(
713                 draft_f=string_io,
714                 draftpath=const1,
715                 cmd_f=sys.stdout,
716                 converter=converter,
717             )
718
719             captured = capsys.readouterr()
720             lines = captured.out.splitlines()[4:]
721             assert "send-message" in lines.pop(0)
722             assert "Related" in lines.pop(0)
723             assert "group-related" in lines.pop(0)
724             assert "tag-entry" in lines.pop(0)
725             assert "Logo" in lines.pop(0)
726             assert "content-id" in lines.pop(0)
727             assert "toggle-unlink" in lines.pop(0)
728             assert "logo.png" in lines.pop(0)
729             assert "tag-entry" in lines.pop(0)
730             assert "Alternative" in lines.pop(0)
731             assert "group-alternatives" in lines.pop(0)
732             assert "tag-entry" in lines.pop(0)
733             assert "HTML" in lines.pop(0)
734             assert "toggle-unlink" in lines.pop(0)
735             assert "part.html" in lines.pop(0)
736             assert "tag-entry" in lines.pop(0)
737             assert "Plain" in lines.pop(0)
738             assert "update-encoding" in lines.pop(0)
739             assert len(lines) == 2
740
741         @pytest.fixture
742         def fake_filewriter(self):
743             class FileWriter:
744                 def __init__(self):
745                     self._writes = []
746
747                 def __call__(self, path, content, mode="w", **kwargs):
748                     self._writes.append((path, content))
749
750                 def pop(self, index=-1):
751                     return self._writes.pop(index)
752
753             return FileWriter()
754
755         @pytest.fixture
756         def markdown_non_converter(self, const1, const2):
757             return lambda s, text: f"{const1}{text}{const2}"
758
759         def test_converter_tree_basic(
760             self, const1, const2, fake_filewriter, markdown_non_converter
761         ):
762             path = pathlib.Path(const2)
763             tree = convert_markdown_to_html(
764                 const1, path, filewriter_fn=fake_filewriter
765             )
766
767             assert tree.subtype == "alternative"
768             assert len(tree.children) == 2
769             assert tree.children[0].subtype == "plain"
770             assert tree.children[0].path == path
771             assert tree.children[0].orig
772             assert tree.children[1].subtype == "html"
773             assert tree.children[1].path == path.with_suffix(".html")
774
775         def test_converter_writes(
776             self,
777             const1,
778             const2,
779             fake_filewriter,
780             monkeypatch,
781             markdown_non_converter,
782         ):
783             path = pathlib.Path(const2)
784
785             with monkeypatch.context() as m:
786                 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
787                 convert_markdown_to_html(
788                     const1, path, filewriter_fn=fake_filewriter
789                 )
790
791             assert (path, const1) == fake_filewriter.pop(0)
792             assert (
793                 path.with_suffix(".html"),
794                 markdown_non_converter(None, const1),
795             ) == fake_filewriter.pop(0)
796
797         def test_markdown_inline_image_processor(self):
798             imgpath1 = "file:/path/to/image.png"
799             imgpath2 = "file:///path/to/image.png?url=params"
800             imgpath3 = "/path/to/image.png"
801             text = f"""![inline local image]({imgpath1})
802                        ![image inlined
803                          with newline]({imgpath2})
804                        ![image local path]({imgpath3})"""
805             text, html, images = markdown_with_inline_image_support(text)
806
807             # local paths have been normalised to URLs:
808             imgpath3 = f"file://{imgpath3}"
809
810             assert 'src="cid:' in html
811             assert "](cid:" in text
812             assert len(images) == 3
813             assert imgpath1 in images
814             assert imgpath2 in images
815             assert imgpath3 in images
816             assert images[imgpath1].cid != images[imgpath2].cid
817             assert images[imgpath1].cid != images[imgpath3].cid
818             assert images[imgpath2].cid != images[imgpath3].cid
819
820         def test_markdown_inline_image_processor_title_to_desc(self, const1):
821             imgpath = "file:///path/to/image.png"
822             text = f'![inline local image]({imgpath} "{const1}")'
823             text, html, images = markdown_with_inline_image_support(text)
824             assert images[imgpath].desc == const1
825
826         def test_markdown_inline_image_processor_alt_to_desc(self, const1):
827             imgpath = "file:///path/to/image.png"
828             text = f"![{const1}]({imgpath})"
829             text, html, images = markdown_with_inline_image_support(text)
830             assert images[imgpath].desc == const1
831
832         def test_markdown_inline_image_processor_title_over_alt_desc(
833             self, const1, const2
834         ):
835             imgpath = "file:///path/to/image.png"
836             text = f'![{const1}]({imgpath} "{const2}")'
837             text, html, images = markdown_with_inline_image_support(text)
838             assert images[imgpath].desc == const2
839
840         def test_markdown_inline_image_not_external(self):
841             imgpath = "https://path/to/image.png"
842             text = f"![inline image]({imgpath})"
843             text, html, images = markdown_with_inline_image_support(text)
844
845             assert 'src="cid:' not in html
846             assert "](cid:" not in text
847             assert len(images) == 0
848
849         def test_markdown_inline_image_local_file(self):
850             imgpath = "/path/to/image.png"
851             text = f"![inline image]({imgpath})"
852             text, html, images = markdown_with_inline_image_support(text)
853
854             for k, v in images.items():
855                 assert k == f"file://{imgpath}"
856                 break
857
858         @pytest.fixture
859         def test_png(self):
860             return (
861                 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
862                 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
863             )
864
865         def test_markdown_inline_image_processor_base64(self, test_png):
866             text = f"![1px white inlined]({test_png})"
867             text, html, images = markdown_with_inline_image_support(text)
868
869             assert 'src="cid:' in html
870             assert "](cid:" in text
871             assert len(images) == 1
872             assert test_png in images
873
874         def test_converter_tree_inline_image_base64(
875             self, test_png, const1, fake_filewriter
876         ):
877             text = f"![inline base64 image]({test_png})"
878             path = pathlib.Path(const1)
879             tree = convert_markdown_to_html(
880                 text, path, filewriter_fn=fake_filewriter
881             )
882
883             assert tree.subtype == "relative"
884             assert tree.children[1].subtype == "png"
885             written = fake_filewriter.pop()
886             assert tree.children[1].path == written[0]
887             assert written[1] == request.urlopen(test_png).read()
888
889         def test_inline_image_collection(
890             self, test_png, const1, const2, fake_filewriter
891         ):
892             test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
893             relparts = collect_inline_images(
894                 test_images, filewriter_fn=fake_filewriter
895             )
896
897             written = fake_filewriter.pop()
898             assert b"PNG" in written[1]
899
900             assert relparts[0].subtype == "png"
901             assert relparts[0].path == written[0]
902             assert relparts[0].cid == const1
903             assert relparts[0].desc.endswith(const2)
904
905 except ImportError:
906     pass