]> 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:

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