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

d1f9eaaf6381a1103230ae24ff80c067c169d389
[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 \
13 #       --css-file $my_confdir/htmlmail.css |'<enter>\
14 #       <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
15 #     " "Convert message into a modern MIME tree with inline images"
16 #
17 #     (Yes, we need to call source twice, as mutt only starts to process output
18 #     from a source command when the command exits, and since we need to react
19 #     to the output, we need to be invoked again, using a $my_ variable to pass
20 #     information)
21 #
22 # Requirements:
23 #   - python3
24 #   - python3-markdown
25 #   - python3-beautifulsoup4
26 # Optional:
27 #   - pytest
28 #   - Pynliner, provides --css-file and thus inline styling of HTML output
29 #   - Pygments, then syntax highlighting for fenced code is enabled
30 #
31 # Latest version:
32 #   https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
33 #
34 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
35 # Released under the GPL-2+ licence, just like Mutt itself.
36 #
37
38 import sys
39 import os.path
40 import pathlib
41 import markdown
42 import tempfile
43 import argparse
44 import re
45 import mimetypes
46 import bs4
47 import xml.etree.ElementTree as etree
48 import io
49 import enum
50 from contextlib import contextmanager
51 from collections import namedtuple, OrderedDict
52 from markdown.extensions import Extension
53 from markdown.blockprocessors import BlockProcessor
54 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
55 from email.utils import make_msgid
56 from urllib import request
57
58
59 def parse_cli_args(*args, **kwargs):
60     parser = argparse.ArgumentParser(
61         description=(
62             "NeoMutt helper to turn text/markdown email parts "
63             "into full-fledged MIME trees"
64         )
65     )
66     parser.epilog = (
67         "Copyright © 2023 martin f. krafft <madduck@madduck.net>.\n"
68         "Released under the MIT licence"
69     )
70
71     parser.add_argument(
72         "--extensions",
73         metavar="EXT[,EXT[,EXT]]",
74         type=str,
75         default="",
76         help="Markdown extension to use (comma-separated list)",
77     )
78
79     if _PYNLINER:
80         parser.add_argument(
81             "--css-file",
82             metavar="FILE",
83             type=pathlib.Path,
84             default=os.devnull,
85             help="CSS file to merge with the final HTML",
86         )
87     else:
88         parser.set_defaults(css_file=None)
89
90     parser.add_argument(
91         "--related-to-html-only",
92         action="store_true",
93         help="Make related content be sibling to HTML parts only",
94     )
95
96     def positive_integer(value):
97         try:
98             if int(value) > 0:
99                 return int(value)
100
101         except ValueError:
102             pass
103
104         raise ValueError("Must be a positive integer")
105
106     parser.add_argument(
107         "--max-number-other-attachments",
108         metavar="INTEGER",
109         type=positive_integer,
110         default=20,
111         help="Maximum number of other attachments to expect",
112     )
113
114     parser.add_argument(
115         "--only-build",
116         "--just-build",
117         action="store_true",
118         help="Only build, don't send the message",
119     )
120
121     parser.add_argument(
122         "--tempdir",
123         metavar="DIR",
124         type=pathlib.Path,
125         help="Specify temporary directory to use for attachments",
126     )
127
128     parser.add_argument(
129         "--debug-commands",
130         action="store_true",
131         help="Turn on debug logging of commands generated to stderr",
132     )
133
134     parser.add_argument(
135         "--debug-walk",
136         action="store_true",
137         help="Turn on debugging to stderr of the MIME tree walk",
138     )
139
140     parser.add_argument(
141         "--dump-html",
142         metavar="FILE",
143         type=pathlib.Path,
144         help="Write the generated HTML to the file",
145     )
146
147     subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
148     massage_p = subp.add_parser(
149         "massage", help="Massaging phase (internal use)"
150     )
151
152     massage_p.add_argument(
153         "--write-commands-to",
154         "-o",
155         metavar="FILE",
156         dest="cmdpath",
157         type=pathlib.Path,
158         required=True,
159         help="Temporary file path to write commands to",
160     )
161
162     massage_p.add_argument(
163         "MAILDRAFT",
164         nargs="?",
165         type=pathlib.Path,
166         help="If provided, the script is invoked as editor on the mail draft",
167     )
168
169     return parser.parse_args(*args, **kwargs)
170
171
172 # [ FILE I/O HANDLING ] #######################################################
173
174
175 class File:
176
177     class Op(enum.Enum):
178         R = enum.auto()
179         W = enum.auto()
180
181     def __init__(self, path=None, mode="r", content=None, **kwargs):
182         if path:
183             if content:
184                 raise RuntimeError("Cannot specify path and content for File")
185
186             self._path = (
187                 path if isinstance(path, pathlib.Path) else pathlib.Path(path)
188             )
189         else:
190             self._path = None
191
192         if content and not re.search(r"[r+]", mode):
193             raise RuntimeError("Cannot specify content without read mode")
194
195         self._cache = {
196             File.Op.R: [content] if content else [],
197             File.Op.W: []
198         }
199         self._lastop = None
200         self._mode = mode
201         self._kwargs = kwargs
202         self._file = None
203
204     def open(self):
205         if self._path:
206             self._file = open(self._path, self._mode, **self._kwargs)
207         elif "b" in self._mode:
208             self._file = io.BytesIO()
209         else:
210             self._file = io.StringIO()
211
212     def __enter__(self):
213         self.open()
214         return self
215
216     def __exit__(self, exc_type, exc_val, exc_tb):
217         self.close()
218
219     def close(self):
220         self._file.close()
221         self._file = None
222         self._cache[File.Op.R] = self._cache[File.Op.W]
223         self._lastop = None
224
225     def _get_cache(self, op):
226         return (b"" if "b" in self._mode else "").join(self._cache[op])
227
228     def _add_to_cache(self, op, s):
229         self._cache[op].append(s)
230
231     def read(self, *, cache=True):
232         if cache and self._cache[File.Op.R]:
233             return self._get_cache(File.Op.R)
234
235         if self._lastop == File.Op.W:
236             try:
237                 self._file.seek(0)
238             except io.UnsupportedOperation:
239                 pass
240
241         self._lastop = File.Op.R
242
243         if cache:
244             self._add_to_cache(File.Op.R, self._file.read())
245             return self._get_cache(File.Op.R)
246         else:
247             return self._file.read()
248
249     def write(self, s, *, cache=True):
250
251         if self._lastop == File.Op.R:
252             try:
253                 self._file.seek(0)
254             except io.UnsupportedOperation:
255                 pass
256
257         if cache:
258             self._add_to_cache(File.Op.W, s)
259
260         self._cache[File.Op.R] = self._cache[File.Op.W]
261
262         written = self._file.write(s)
263         self._file.flush()
264         self._lastop = File.Op.W
265         return written
266
267     path = property(lambda s: s._path)
268
269     def __repr__(self):
270         return (
271             f'<File path={self._path or "(buffered)"} open={bool(self._file)} '
272             f"rcache={sum(len(c) for c in self._rcache) if self._rcache is not None else False} "
273             f"wcache={sum(len(c) for c in self._wcache) if self._wcache is not None else False}>"
274         )
275
276
277 class FileFactory:
278     def __init__(self):
279         self._files = []
280
281     def __call__(self, path=None, mode="r", content=None, **kwargs):
282         f = File(path, mode, content, **kwargs)
283         self._files.append(f)
284         return f
285
286     def __len__(self):
287         return self._files.__len__()
288
289     def pop(self, idx=-1):
290         return self._files.pop(idx)
291
292     def __getitem__(self, idx):
293         return self._files.__getitem__(idx)
294
295     def __contains__(self, f):
296         return self._files.__contains__(f)
297
298
299 class FakeFileFactory(FileFactory):
300     def __init__(self):
301         super().__init__()
302         self._paths2files = OrderedDict()
303
304     def __call__(self, path=None, mode="r", content=None, **kwargs):
305         if path in self._paths2files:
306             return self._paths2files[path]
307
308         f = super().__call__(None, mode, content, **kwargs)
309         self._paths2files[path] = f
310
311         mypath = path
312
313         class FakeFile(File):
314             path = mypath
315
316         # this is quality Python! We do this so that the fake file, which has
317         # no path, fake-pretends to have a path for testing purposes.
318
319         f.__class__ = FakeFile
320         return f
321
322     def __getitem__(self, path):
323         return self._paths2files.__getitem__(path)
324
325     def get(self, path, default):
326         return self._paths2files.get(path, default)
327
328     def pop(self, last=True):
329         return self._paths2files.popitem(last)
330
331     def __repr__(self):
332         return (
333             f"<FakeFileFactory nfiles={len(self._files)} "
334             f"paths={len(self._paths2files)}>"
335         )
336
337
338 # [ IMAGE HANDLING ] ##########################################################
339
340
341 InlineImageInfo = namedtuple(
342     "InlineImageInfo", ["cid", "desc"], defaults=[None]
343 )
344
345
346 class ImageRegistry:
347     def __init__(self):
348         self._images = OrderedDict()
349
350     def register(self, path, description=None):
351         # path = str(pathlib.Path(path).expanduser())
352         path = os.path.expanduser(path)
353         if path.startswith("/"):
354             path = f"file://{path}"
355         cid = make_msgid()[1:-1]
356         self._images[path] = InlineImageInfo(cid, description)
357         return cid
358
359     def __iter__(self):
360         return self._images.__iter__()
361
362     def __getitem__(self, idx):
363         return self._images.__getitem__(idx)
364
365     def __len__(self):
366         return self._images.__len__()
367
368     def items(self):
369         return self._images.items()
370
371     def __repr__(self):
372         return f"<ImageRegistry(items={len(self._images)})>"
373
374     def __str__(self):
375         return self._images.__str__()
376
377
378 class InlineImageExtension(Extension):
379     class RelatedImageInlineProcessor(ImageInlineProcessor):
380         def __init__(self, re, md, registry):
381             super().__init__(re, md)
382             self._registry = registry
383
384         def handleMatch(self, m, data):
385             el, start, end = super().handleMatch(m, data)
386             if "src" in el.attrib:
387                 src = el.attrib["src"]
388                 if "://" not in src or src.startswith("file://"):
389                     # We only inline local content
390                     cid = self._registry.register(
391                         el.attrib["src"],
392                         el.attrib.get("title", el.attrib.get("alt")),
393                     )
394                     el.attrib["src"] = f"cid:{cid}"
395             return el, start, end
396
397     def __init__(self, registry):
398         super().__init__()
399         self._image_registry = registry
400
401     INLINE_PATTERN_NAME = "image_link"
402
403     def extendMarkdown(self, md):
404         md.registerExtension(self)
405         inline_image_proc = self.RelatedImageInlineProcessor(
406             IMAGE_LINK_RE, md, self._image_registry
407         )
408         md.inlinePatterns.register(
409             inline_image_proc, InlineImageExtension.INLINE_PATTERN_NAME, 150
410         )
411
412
413 def markdown_with_inline_image_support(
414     text,
415     *,
416     mdwn=None,
417     image_registry=None,
418     extensions=None,
419     extension_configs=None,
420 ):
421     registry = (
422         image_registry if image_registry is not None else ImageRegistry()
423     )
424     inline_image_handler = InlineImageExtension(registry=registry)
425     extensions = extensions or []
426     extensions.append(inline_image_handler)
427     mdwn = markdown.Markdown(
428         extensions=extensions, extension_configs=extension_configs
429     )
430
431     htmltext = mdwn.convert(text)
432
433     def replace_image_with_cid(matchobj):
434         for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
435             if m in registry:
436                 return f"(cid:{registry[m].cid}"
437         return matchobj.group(0)
438
439     text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
440     return text, htmltext, registry, mdwn
441
442
443 # [ CSS STYLING ] #############################################################
444
445
446 try:
447     import pynliner
448
449     _PYNLINER = True
450
451 except ImportError:
452     _PYNLINER = False
453
454 try:
455     from pygments.formatters import get_formatter_by_name
456
457     _CODEHILITE_CLASS = "codehilite"
458
459     _PYGMENTS_CSS = get_formatter_by_name(
460         "html", style="default"
461     ).get_style_defs(f".{_CODEHILITE_CLASS}")
462
463 except ImportError:
464     _PYGMENTS_CSS = None
465
466
467 def apply_styling(html, css):
468     return (
469         pynliner.Pynliner()
470         .from_string(html)
471         .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
472         .run()
473     )
474
475
476 # [ QUOTE HANDLING ] ##########################################################
477
478
479 class QuoteToAdmonitionExtension(Extension):
480     class EmailQuoteBlockProcessor(BlockProcessor):
481         RE = re.compile(r"(?:^|\n)>\s*(.*)")
482
483         def __init__(self, parser):
484             super().__init__(parser)
485             self._title = None
486             self._disable = False
487
488         def test(self, parent, blocks):
489             if self._disable:
490                 return False
491
492             if markdown.util.nearing_recursion_limit():
493                 return False
494
495             lines = blocks.splitlines()
496             if len(lines) < 2:
497                 if not self._title:
498                     return False
499
500                 elif not self.RE.search(lines[0]):
501                     return False
502
503                 return len(lines) > 0
504
505             elif not self.RE.search(lines[0]) and self.RE.search(lines[1]):
506                 return True
507
508             elif self._title and self.RE.search(lines[1]):
509                 return True
510
511             return False
512
513         def run(self, parent, blocks):
514             quotelines = blocks.pop(0).splitlines()
515
516             cont = bool(self._title)
517             if not self.RE.search(quotelines[0]):
518                 self._title = quotelines.pop(0)
519
520             admonition = etree.SubElement(parent, "div")
521             admonition.set(
522                 "class", f"admonition quote{' continued' if cont else ''}"
523             )
524             self.parser.parseChunk(admonition, self._title)
525
526             admonition[0].set("class", "admonition-title")
527             with self.disable():
528                 self.parser.parseChunk(
529                     admonition, "\n".join(quotelines)
530                 )
531
532         @contextmanager
533         def disable(self):
534             self._disable = True
535             yield True
536             self._disable = False
537
538         @classmethod
539         def clean(klass, line):
540             m = klass.RE.match(line)
541             return m.group(1) if m else line
542
543     def extendMarkdown(self, md):
544         md.registerExtension(self)
545         email_quote_proc = self.EmailQuoteBlockProcessor(md.parser)
546         md.parser.blockprocessors.register(email_quote_proc, "emailquote", 25)
547
548
549 # [ PARTS GENERATION ] ########################################################
550
551
552 class Part(
553     namedtuple(
554         "Part",
555         ["type", "subtype", "path", "desc", "cid", "orig"],
556         defaults=[None, None, False],
557     )
558 ):
559     def __str__(self):
560         ret = f"<{self.type}/{self.subtype}>"
561         if self.cid:
562             ret = f"{ret} cid:{self.cid}"
563         if self.orig:
564             ret = f"{ret} ORIGINAL"
565         return ret
566
567
568 class Multipart(
569     namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
570 ):
571     def __str__(self):
572         return f"<multipart/{self.subtype}> children={len(self.children)}"
573
574     def __hash__(self):
575         return hash(str(self.subtype) + "".join(str(self.children)))
576
577
578 def collect_inline_images(
579     image_registry, *, tempdir=None, filefactory=FileFactory()
580 ):
581     relparts = []
582     for path, info in image_registry.items():
583         if path.startswith("cid:"):
584             continue
585
586         data = request.urlopen(path)
587
588         mimetype = data.headers["Content-Type"]
589         ext = mimetypes.guess_extension(mimetype)
590         tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
591         path = pathlib.Path(tempfilename[1])
592
593         with filefactory(path, "w+b") as out_f:
594             out_f.write(data.read())
595
596         # filewriter_fn(path, data.read(), "w+b")
597
598         desc = (
599             f'Inline image: "{info.desc}"'
600             if info.desc
601             else f"Inline image {str(len(relparts)+1)}"
602         )
603         relparts.append(
604             Part(*mimetype.split("/"), path, cid=info.cid, desc=desc)
605         )
606
607     return relparts
608
609
610 EMAIL_SIG_SEP = "\n-- \n"
611 HTML_SIG_MARKER = "=htmlsig "
612
613
614 def make_html_doc(body, sig=None):
615     ret = (
616         "<!DOCTYPE html>\n"
617         "<html>\n"
618         "<head>\n"
619         '<meta http-equiv="content-type" content="text/html; charset=UTF-8">\n'  # noqa: E501
620         '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n'  # noqa: E501
621         "</head>\n"
622         "<body>\n"
623         f"{body}\n"
624     )
625
626     if sig:
627         nl = "\n"
628         ret = (
629             f'{ret}<div id="signature"><span class="sig_separator">{EMAIL_SIG_SEP.strip(nl)}</span>\n'  # noqa: E501
630             f"{sig}\n"
631             "</div>"
632         )
633
634     return f"{ret}\n  </body>\n</html>"
635
636
637 def make_text_mail(text, sig=None):
638     return EMAIL_SIG_SEP.join((text, sig)) if sig else text
639
640
641 def extract_signature(text, *, filefactory=FileFactory()):
642     parts = text.split(EMAIL_SIG_SEP, 1)
643     if len(parts) == 1:
644         return text, None, None
645
646     lines = parts[1].splitlines()
647     if lines[0].startswith(HTML_SIG_MARKER):
648         path = pathlib.Path(re.split(r" +", lines.pop(0), maxsplit=1)[1])
649         textsig = "\n".join(lines)
650
651         with filefactory(path.expanduser()) as sig_f:
652             sig_input = sig_f.read()
653
654         soup = bs4.BeautifulSoup(sig_input, "html.parser")
655
656         style = str(soup.style.extract()) if soup.style else ""
657         for sig_selector in (
658             "#signature",
659             "#signatur",
660             "#emailsig",
661             ".signature",
662             ".signatur",
663             ".emailsig",
664             "body",
665             "div",
666         ):
667             sig = soup.select_one(sig_selector)
668             if sig:
669                 break
670
671         if not sig:
672             return parts[0], textsig, style + sig_input
673
674         if sig.attrs.get("id") == "signature":
675             sig = "".join(str(c) for c in sig.children)
676
677         return parts[0], textsig, style + str(sig)
678
679     return parts[0], parts[1], None
680
681
682 def convert_markdown_to_html(
683     draft_f,
684     *,
685     related_to_html_only=False,
686     css_f=None,
687     htmldump_f=None,
688     filefactory=FileFactory(),
689     tempdir=None,
690     extensions=None,
691     extension_configs=None,
692 ):
693     # TODO extension_configs need to be handled differently
694     extension_configs = extension_configs or {}
695     extension_configs.setdefault("pymdownx.highlight", {})[
696         "css_class"
697     ] = _CODEHILITE_CLASS
698
699     extensions = extensions or []
700     extensions.append(QuoteToAdmonitionExtension())
701
702     draft = draft_f.read()
703     origtext, textsig, htmlsig = extract_signature(
704         draft, filefactory=filefactory
705     )
706
707     (
708         origtext,
709         htmltext,
710         image_registry,
711         mdwn,
712     ) = markdown_with_inline_image_support(
713         origtext, extensions=extensions, extension_configs=extension_configs
714     )
715
716     if htmlsig:
717         if not textsig:
718             # TODO: decide what to do if there is no plain-text version
719             raise NotImplementedError("HTML signature but no text alternative")
720
721         soup = bs4.BeautifulSoup(htmlsig, "html.parser")
722         for img in soup.find_all("img"):
723             uri = img.attrs["src"]
724             desc = img.attrs.get("title", img.attrs.get("alt"))
725             cid = image_registry.register(uri, desc)
726             img.attrs["src"] = f"cid:{cid}"
727
728         htmlsig = str(soup)
729
730     elif textsig:
731         (
732             textsig,
733             htmlsig,
734             image_registry,
735             mdwn,
736         ) = markdown_with_inline_image_support(
737             textsig,
738             extensions=extensions,
739             extension_configs=extension_configs,
740             image_registry=image_registry,
741             mdwn=mdwn,
742         )
743
744     origtext = make_text_mail(origtext, textsig)
745     draft_f.write(origtext)
746     textpart = Part(
747         "text", "plain", draft_f.path, "Plain-text version", orig=True
748     )
749
750     htmltext = make_html_doc(htmltext, htmlsig)
751     htmltext = apply_styling(htmltext, css_f.read() if css_f else None)
752
753     if draft_f.path:
754         htmlpath = draft_f.path.with_suffix(".html")
755     else:
756         htmlpath = pathlib.Path(
757             tempfile.mkstemp(suffix=".html", dir=tempdir)[1]
758         )
759     with filefactory(
760         htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
761     ) as out_f:
762         out_f.write(htmltext)
763     htmlpart = Part("text", "html", htmlpath, "HTML version")
764
765     if htmldump_f:
766         htmldump_f.write(htmltext)
767
768     imgparts = collect_inline_images(
769         image_registry, tempdir=tempdir, filefactory=filefactory
770     )
771
772     if related_to_html_only:
773         # If there are inline image part, they will be contained within a
774         # multipart/related part along with the HTML part only
775         if imgparts:
776             # replace htmlpart with a multipart/related container of the HTML
777             # parts and the images
778             htmlpart = Multipart(
779                 "relative", [htmlpart] + imgparts, "Group of related content"
780             )
781
782         return Multipart(
783             "alternative", [textpart, htmlpart], "Group of alternative content"
784         )
785
786     else:
787         # If there are inline image part, they will be siblings to the
788         # multipart/alternative tree within a multipart/related part
789         altpart = Multipart(
790             "alternative", [textpart, htmlpart], "Group of alternative content"
791         )
792         if imgparts:
793             return Multipart(
794                 "relative", [altpart] + imgparts, "Group of related content"
795             )
796         else:
797             return altpart
798
799
800 class MIMETreeDFWalker:
801     def __init__(self, *, visitor_fn=None, debug=False):
802         self._visitor_fn = visitor_fn or self._echovisit
803         self._debug = debug
804
805     def _echovisit(self, node, ancestry, debugprint):
806         debugprint(f"node={node} ancestry={ancestry}")
807
808     def walk(self, root, *, visitor_fn=None):
809         """
810         Recursive function to implement a depth-dirst walk of the MIME-tree
811         rooted at `root`.
812         """
813         if isinstance(root, list):
814             if len(root) > 1:
815                 root = Multipart("mixed", children=root)
816             else:
817                 root = root[0]
818
819         self._walk(
820             root,
821             ancestry=[],
822             descendents=[],
823             visitor_fn=visitor_fn or self._visitor_fn,
824         )
825
826     def _walk(self, node, *, ancestry, descendents, visitor_fn):
827         # Let's start by enumerating the parts at the current level. At the
828         # root level, ancestry will be the empty list, and we expect a
829         # multipart/* container at this level. Later, e.g. within a
830         # mutlipart/alternative container, the subtree will just be the
831         # alternative parts, while the top of the ancestry will be the
832         # multipart/alternative container, which we will process after the
833         # following loop.
834
835         lead = f"{'│ '*len(ancestry)}"
836         if isinstance(node, Multipart):
837             self.debugprint(
838                 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
839             )
840
841             # Depth-first, so push the current container onto the ancestry
842             # stack, then descend …
843             ancestry.append(node)
844             self.debugprint(lead + "│ " * 2)
845             for child in node.children:
846                 self._walk(
847                     child,
848                     ancestry=ancestry,
849                     descendents=descendents,
850                     visitor_fn=visitor_fn,
851                 )
852             assert ancestry.pop() == node
853             sibling_descendents = descendents
854             descendents.extend(node.children)
855
856         else:
857             self.debugprint(f"{lead}├{node}")
858             sibling_descendents = descendents
859
860         if False and ancestry:
861             self.debugprint(lead[:-1] + " │")
862
863         if visitor_fn:
864             visitor_fn(
865                 node, ancestry, sibling_descendents, debugprint=self.debugprint
866             )
867
868     def debugprint(self, s, **kwargs):
869         if self._debug:
870             print(s, file=sys.stderr, **kwargs)
871
872
873 # [ RUN MODES ] ###############################################################
874
875
876 class MuttCommands:
877     """
878     Stupid class to interface writing out Mutt commands. This is quite a hack
879     to deal with the fact that Mutt runs "push" commands in reverse order, so
880     all of a sudden, things become very complicated when mixing with "real"
881     commands.
882
883     Hence we keep two sets of commands, and one set of pushes. Commands are
884     added to the first until a push is added, after which commands are added to
885     the second set of commands.
886
887     On flush(), the first set is printed, followed by the pushes in reverse,
888     and then the second set is printed. All 3 sets are then cleared.
889     """
890
891     def __init__(self, out_f=sys.stdout, *, debug=False):
892         self._cmd1, self._push, self._cmd2 = [], [], []
893         self._out_f = out_f
894         self._debug = debug
895
896     def cmd(self, s):
897         self.debugprint(s)
898         if self._push:
899             self._cmd2.append(s)
900         else:
901             self._cmd1.append(s)
902
903     def push(self, s):
904         s = s.replace('"', r"\"")
905         s = f'push "{s}"'
906         self.debugprint(s)
907         self._push.insert(0, s)
908
909     def flush(self):
910         print(
911             "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
912         )
913         self._cmd1, self._push, self._cmd2 = [], [], []
914
915     def debugprint(self, s, **kwargs):
916         if self._debug:
917             print(s, file=sys.stderr, **kwargs)
918
919
920 def do_setup(
921     *,
922     out_f=sys.stdout,
923     temppath=None,
924     tempdir=None,
925     debug_commands=False,
926 ):
927     temppath = temppath or pathlib.Path(
928         tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
929     )
930     cmds = MuttCommands(out_f, debug=debug_commands)
931
932     editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
933
934     cmds.cmd('set my_editor="$editor"')
935     cmds.cmd('set my_edit_headers="$edit_headers"')
936     cmds.cmd(f'set editor="{editor}"')
937     cmds.cmd("unset edit_headers")
938     cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
939     cmds.push("<first-entry><edit-file>")
940     cmds.flush()
941
942
943 def do_massage(
944     draft_f,
945     cmd_f,
946     *,
947     extensions=None,
948     css_f=None,
949     htmldump_f=None,
950     converter=convert_markdown_to_html,
951     related_to_html_only=True,
952     only_build=False,
953     max_other_attachments=20,
954     tempdir=None,
955     debug_commands=False,
956     debug_walk=False,
957 ):
958     # Here's the big picture: we're being invoked as the editor on the email
959     # draft, and whatever commands we write to the file given as cmdpath will
960     # be run by the second source command in the macro definition.
961
962     # Let's start by cleaning up what the setup did (see above), i.e. we
963     # restore the $editor and $edit_headers variables, and also unset the
964     # variable used to identify the command file we're currently writing
965     # to.
966     cmds = MuttCommands(cmd_f, debug=debug_commands)
967
968     extensions = extensions.split(",") if extensions else []
969     tree = converter(
970         draft_f,
971         css_f=css_f,
972         htmldump_f=htmldump_f,
973         related_to_html_only=related_to_html_only,
974         tempdir=tempdir,
975         extensions=extensions,
976     )
977
978     mimetree = MIMETreeDFWalker(debug=debug_walk)
979
980     state = dict(pos=1, tags={}, parts=1)
981
982     def visitor_fn(item, ancestry, descendents, *, debugprint=None):
983         """
984         Visitor function called for every node (part) of the MIME tree,
985         depth-first, and responsible for telling NeoMutt how to assemble
986         the tree.
987         """
988         KILL_LINE = r"\Ca\Ck"
989
990         if isinstance(item, Part):
991             # We've hit a leaf-node, i.e. an alternative or a related part
992             # with actual content.
993
994             # Let's add the part
995             if item.orig:
996                 # The original source already exists in the NeoMutt tree, but
997                 # the underlying file may have been modified, so we need to
998                 # update the encoding, but that's it:
999                 cmds.push("<first-entry>")
1000                 cmds.push("<update-encoding>")
1001
1002                 # We really just need to be able to assume that at this point,
1003                 # NeoMutt is at position 1, and that we've processed only this
1004                 # part so far. Nevermind about actual attachments, we can
1005                 # safely ignore those as they stay at the end.
1006                 assert state["pos"] == 1
1007                 assert state["parts"] == 1
1008             else:
1009                 # … whereas all other parts need to be added, and they're all
1010                 # considered to be temporary and inline:
1011                 cmds.push(f"<attach-file>{item.path}<enter>")
1012                 cmds.push("<toggle-unlink><toggle-disposition>")
1013
1014                 # This added a part at the end of the list of parts, and that's
1015                 # just how many parts we've seen so far, so it's position in
1016                 # the NeoMutt compose list is the count of parts
1017                 state["parts"] += 1
1018                 state["pos"] = state["parts"]
1019
1020             # If the item (including the original) comes with additional
1021             # information, then we might just as well update the NeoMutt
1022             # tree now:
1023             if item.cid:
1024                 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
1025
1026             # Now for the biggest hack in this script, which is to handle
1027             # attachments, such as PDFs, that aren't related or alternatives.
1028             # The problem is that when we add an inline image, it always gets
1029             # appended to the list, i.e. inserted *after* other attachments.
1030             # Since we don't know the number of attachments, we also cannot
1031             # infer the postition of the new attachment. Therefore, we bubble
1032             # it all the way to the top, only to then move it down again:
1033             if state["pos"] > 1:  # skip for the first part
1034                 for i in range(max_other_attachments):
1035                     # could use any number here, but has to be larger than the
1036                     # number of possible attachments. The performance
1037                     # difference of using a high number is negligible.
1038                     # Bubble up the new part
1039                     cmds.push("<move-up>")
1040
1041                 # As we push the part to the right position in the list (i.e.
1042                 # the last of the subset of attachments this script added), we
1043                 # must handle the situation that subtrees are skipped by
1044                 # NeoMutt. Hence, the actual number of positions to move down
1045                 # is decremented by the number of descendents so far
1046                 # encountered.
1047                 for i in range(1, state["pos"] - len(descendents)):
1048                     cmds.push("<move-down>")
1049
1050         elif isinstance(item, Multipart):
1051             # This node has children, but we already visited them (see
1052             # above). The tags dictionary of State should contain a list of
1053             # their positions in the NeoMutt compose window, so iterate those
1054             # and tag the parts there:
1055             n_tags = len(state["tags"][item])
1056             for tag in state["tags"][item]:
1057                 cmds.push(f"<jump>{tag}<enter><tag-entry>")
1058
1059             if item.subtype == "alternative":
1060                 cmds.push("<group-alternatives>")
1061             elif item.subtype in ("relative", "related"):
1062                 cmds.push("<group-related>")
1063             elif item.subtype == "multilingual":
1064                 cmds.push("<group-multilingual>")
1065             else:
1066                 raise NotImplementedError(
1067                     f"Handling of multipart/{item.subtype} is not implemented"
1068                 )
1069
1070             state["pos"] -= n_tags - 1
1071             state["parts"] += 1
1072
1073         else:
1074             # We should never get here
1075             raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
1076
1077         # If the item has a description, we might just as well add it
1078         if item.desc:
1079             cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
1080
1081         if ancestry:
1082             # If there's an ancestry, record the current (assumed) position in
1083             # the NeoMutt compose window as needed-to-tag by our direct parent
1084             # (i.e. the last item of the ancestry)
1085             state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
1086
1087             lead = "│ " * (len(ancestry) + 1) + "* "
1088             debugprint(
1089                 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
1090                 f"{lead}descendents={[d.subtype for d in descendents]}\n"
1091                 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
1092                 f"{lead}pos={state['pos']}, parts={state['parts']}"
1093             )
1094
1095     # -----------------
1096     # End of visitor_fn
1097
1098     # Let's walk the tree and visit every node with our fancy visitor
1099     # function
1100     mimetree.walk(tree, visitor_fn=visitor_fn)
1101
1102     if not only_build:
1103         cmds.push("<send-message>")
1104
1105     # Finally, cleanup. Since we're responsible for removing the temporary
1106     # file, how's this for a little hack?
1107     try:
1108         filename = cmd_f.name
1109     except AttributeError:
1110         filename = "pytest_internal_file"
1111     cmds.cmd(f"source 'rm -f {filename}|'")
1112     cmds.cmd('set editor="$my_editor"')
1113     cmds.cmd('set edit_headers="$my_edit_headers"')
1114     cmds.cmd("unset my_editor")
1115     cmds.cmd("unset my_edit_headers")
1116     cmds.cmd("unset my_mdwn_postprocess_cmd_file")
1117     cmds.flush()
1118
1119
1120 # [ CLI ENTRY ] ###############################################################
1121
1122 if __name__ == "__main__":
1123     args = parse_cli_args()
1124
1125     if args.mode is None:
1126         do_setup(
1127             tempdir=args.tempdir,
1128             debug_commands=args.debug_commands,
1129         )
1130
1131     elif args.mode == "massage":
1132         with (
1133             File(args.MAILDRAFT, "r+") as draft_f,
1134             File(args.cmdpath, "w") as cmd_f,
1135             File(args.css_file, "r") as css_f,
1136             File(args.dump_html, "w") as htmldump_f,
1137         ):
1138             do_massage(
1139                 draft_f,
1140                 cmd_f,
1141                 extensions=args.extensions,
1142                 css_f=css_f,
1143                 htmldump_f=htmldump_f,
1144                 related_to_html_only=args.related_to_html_only,
1145                 max_other_attachments=args.max_number_other_attachments,
1146                 only_build=args.only_build,
1147                 tempdir=args.tempdir,
1148                 debug_commands=args.debug_commands,
1149                 debug_walk=args.debug_walk,
1150             )
1151
1152
1153 # [ TESTS ] ###################################################################
1154
1155 try:
1156     import pytest
1157
1158     class Tests:
1159         @pytest.fixture
1160         def const1(self):
1161             return "Curvature Vest Usher Dividing+T#iceps Senior"
1162
1163         @pytest.fixture
1164         def const2(self):
1165             return "Habitant Celestial 2litzy Resurf/ce Headpiece Harmonics"
1166
1167         @pytest.fixture
1168         def fakepath(self):
1169             return pathlib.Path("/does/not/exist")
1170
1171         @pytest.fixture
1172         def fakepath2(self):
1173             return pathlib.Path("/does/not/exist/either")
1174
1175         # NOTE: tests using the capsys fixture must specify sys.stdout to the
1176         # functions they call, else old stdout is used and not captured
1177
1178         @pytest.mark.muttctrl
1179         def test_MuttCommands_cmd(self, const1, const2, capsys):
1180             "Assert order of commands"
1181             cmds = MuttCommands(out_f=sys.stdout)
1182             cmds.cmd(const1)
1183             cmds.cmd(const2)
1184             cmds.flush()
1185             captured = capsys.readouterr()
1186             assert captured.out == "\n".join((const1, const2, ""))
1187
1188         @pytest.mark.muttctrl
1189         def test_MuttCommands_push(self, const1, const2, capsys):
1190             "Assert reverse order of pushes"
1191             cmds = MuttCommands(out_f=sys.stdout)
1192             cmds.push(const1)
1193             cmds.push(const2)
1194             cmds.flush()
1195             captured = capsys.readouterr()
1196             assert (
1197                 captured.out
1198                 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
1199             )
1200
1201         @pytest.mark.muttctrl
1202         def test_MuttCommands_push_escape(self, const1, const2, capsys):
1203             cmds = MuttCommands(out_f=sys.stdout)
1204             cmds.push(f'"{const1}"')
1205             cmds.flush()
1206             captured = capsys.readouterr()
1207             assert f'"\\"{const1}\\""' in captured.out
1208
1209         @pytest.mark.muttctrl
1210         def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
1211             "Assert reverse order of pushes"
1212             cmds = MuttCommands(out_f=sys.stdout)
1213             lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
1214             for i in range(2):
1215                 cmds.cmd(lines[4 * i + 0])
1216                 cmds.cmd(lines[4 * i + 1])
1217                 cmds.push(lines[4 * i + 2])
1218                 cmds.push(lines[4 * i + 3])
1219             cmds.flush()
1220
1221             captured = capsys.readouterr()
1222             lines_out = captured.out.splitlines()
1223             assert lines[0] in lines_out[0]
1224             assert lines[1] in lines_out[1]
1225             assert lines[7] in lines_out[2]
1226             assert lines[6] in lines_out[3]
1227             assert lines[3] in lines_out[4]
1228             assert lines[2] in lines_out[5]
1229             assert lines[4] in lines_out[6]
1230             assert lines[5] in lines_out[7]
1231
1232         @pytest.fixture
1233         def mime_tree_related_to_alternative(self):
1234             return Multipart(
1235                 "relative",
1236                 children=[
1237                     Multipart(
1238                         "alternative",
1239                         children=[
1240                             Part(
1241                                 "text",
1242                                 "plain",
1243                                 "part.txt",
1244                                 desc="Plain",
1245                                 orig=True,
1246                             ),
1247                             Part("text", "html", "part.html", desc="HTML"),
1248                         ],
1249                         desc="Alternative",
1250                     ),
1251                     Part(
1252                         "text", "png", "logo.png", cid="logo.png", desc="Logo"
1253                     ),
1254                 ],
1255                 desc="Related",
1256             )
1257
1258         @pytest.fixture
1259         def mime_tree_related_to_html(self):
1260             return Multipart(
1261                 "alternative",
1262                 children=[
1263                     Part(
1264                         "text",
1265                         "plain",
1266                         "part.txt",
1267                         desc="Plain",
1268                         orig=True,
1269                     ),
1270                     Multipart(
1271                         "relative",
1272                         children=[
1273                             Part("text", "html", "part.html", desc="HTML"),
1274                             Part(
1275                                 "text",
1276                                 "png",
1277                                 "logo.png",
1278                                 cid="logo.png",
1279                                 desc="Logo",
1280                             ),
1281                         ],
1282                         desc="Related",
1283                     ),
1284                 ],
1285                 desc="Alternative",
1286             )
1287
1288         @pytest.fixture
1289         def mime_tree_nested(self):
1290             return Multipart(
1291                 "relative",
1292                 children=[
1293                     Multipart(
1294                         "alternative",
1295                         children=[
1296                             Part(
1297                                 "text",
1298                                 "plain",
1299                                 "part.txt",
1300                                 desc="Plain",
1301                                 orig=True,
1302                             ),
1303                             Multipart(
1304                                 "alternative",
1305                                 children=[
1306                                     Part(
1307                                         "text",
1308                                         "plain",
1309                                         "part.txt",
1310                                         desc="Nested plain",
1311                                     ),
1312                                     Part(
1313                                         "text",
1314                                         "html",
1315                                         "part.html",
1316                                         desc="Nested HTML",
1317                                     ),
1318                                 ],
1319                                 desc="Nested alternative",
1320                             ),
1321                         ],
1322                         desc="Alternative",
1323                     ),
1324                     Part(
1325                         "text",
1326                         "png",
1327                         "logo.png",
1328                         cid="logo.png",
1329                         desc="Logo",
1330                     ),
1331                 ],
1332                 desc="Related",
1333             )
1334
1335         @pytest.mark.treewalk
1336         def test_MIMETreeDFWalker_depth_first_walk(
1337             self, mime_tree_related_to_alternative
1338         ):
1339             mimetree = MIMETreeDFWalker()
1340
1341             items = []
1342
1343             def visitor_fn(item, ancestry, descendents, debugprint):
1344                 items.append((item, len(ancestry), len(descendents)))
1345
1346             mimetree.walk(
1347                 mime_tree_related_to_alternative, visitor_fn=visitor_fn
1348             )
1349             assert len(items) == 5
1350             assert items[0][0].subtype == "plain"
1351             assert items[0][1] == 2
1352             assert items[0][2] == 0
1353             assert items[1][0].subtype == "html"
1354             assert items[1][1] == 2
1355             assert items[1][2] == 0
1356             assert items[2][0].subtype == "alternative"
1357             assert items[2][1] == 1
1358             assert items[2][2] == 2
1359             assert items[3][0].subtype == "png"
1360             assert items[3][1] == 1
1361             assert items[3][2] == 2
1362             assert items[4][0].subtype == "relative"
1363             assert items[4][1] == 0
1364             assert items[4][2] == 4
1365
1366         @pytest.mark.treewalk
1367         def test_MIMETreeDFWalker_list_to_mixed(self, const1):
1368             mimetree = MIMETreeDFWalker()
1369             items = []
1370
1371             def visitor_fn(item, ancestry, descendents, debugprint):
1372                 items.append(item)
1373
1374             p = Part("text", "plain", const1)
1375             mimetree.walk([p], visitor_fn=visitor_fn)
1376             assert items[-1].subtype == "plain"
1377             mimetree.walk([p, p], visitor_fn=visitor_fn)
1378             assert items[-1].subtype == "mixed"
1379
1380         @pytest.mark.treewalk
1381         def test_MIMETreeDFWalker_visitor_in_constructor(
1382             self, mime_tree_related_to_alternative
1383         ):
1384             items = []
1385
1386             def visitor_fn(item, ancestry, descendents, debugprint):
1387                 items.append(item)
1388
1389             mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
1390             mimetree.walk(mime_tree_related_to_alternative)
1391             assert len(items) == 5
1392
1393         @pytest.fixture
1394         def string_io(self, const1, text=None):
1395             return StringIO(text or const1)
1396
1397         @pytest.mark.massage
1398         def test_do_massage_basic(self):
1399             def converter(draft_f, **kwargs):
1400                 return Part("text", "plain", draft_f.path, orig=True)
1401
1402             with File() as draft_f, File() as cmd_f:
1403                 do_massage(
1404                     draft_f=draft_f,
1405                     cmd_f=cmd_f,
1406                     converter=converter,
1407                 )
1408                 lines = cmd_f.read().splitlines()
1409
1410             assert "send-message" in lines.pop(0)
1411             assert "update-encoding" in lines.pop(0)
1412             assert "first-entry" in lines.pop(0)
1413             assert "source 'rm -f " in lines.pop(0)
1414             assert '="$my_editor"' in lines.pop(0)
1415             assert '="$my_edit_headers"' in lines.pop(0)
1416             assert "unset my_editor" == lines.pop(0)
1417             assert "unset my_edit_headers" == lines.pop(0)
1418             assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
1419
1420         @pytest.mark.massage
1421         def test_do_massage_fulltree(self, mime_tree_related_to_alternative):
1422             def converter(draft_f, **kwargs):
1423                 return mime_tree_related_to_alternative
1424
1425             max_attachments = 5
1426
1427             with File() as draft_f, File() as cmd_f:
1428                 do_massage(
1429                     draft_f=draft_f,
1430                     cmd_f=cmd_f,
1431                     max_other_attachments=max_attachments,
1432                     converter=converter,
1433                 )
1434                 lines = cmd_f.read().splitlines()[:-6]
1435
1436             assert "first-entry" in lines.pop()
1437             assert "update-encoding" in lines.pop()
1438             assert "Plain" in lines.pop()
1439             assert "part.html" in lines.pop()
1440             assert "toggle-unlink" in lines.pop()
1441             for i in range(max_attachments):
1442                 assert "move-up" in lines.pop()
1443             assert "move-down" in lines.pop()
1444             assert "HTML" in lines.pop()
1445             assert "jump>1" in lines.pop()
1446             assert "jump>2" in lines.pop()
1447             assert "group-alternatives" in lines.pop()
1448             assert "Alternative" in lines.pop()
1449             assert "logo.png" in lines.pop()
1450             assert "toggle-unlink" in lines.pop()
1451             assert "content-id" in lines.pop()
1452             for i in range(max_attachments):
1453                 assert "move-up" in lines.pop()
1454             assert "move-down" in lines.pop()
1455             assert "Logo" in lines.pop()
1456             assert "jump>1" in lines.pop()
1457             assert "jump>4" in lines.pop()
1458             assert "group-related" in lines.pop()
1459             assert "Related" in lines.pop()
1460             assert "send-message" in lines.pop()
1461             assert len(lines) == 0
1462
1463         @pytest.mark.massage
1464         def test_mime_tree_relative_within_alternative(
1465             self, mime_tree_related_to_html
1466         ):
1467             def converter(draft_f, **kwargs):
1468                 return mime_tree_related_to_html
1469
1470             with File() as draft_f, File() as cmd_f:
1471                 do_massage(
1472                     draft_f=draft_f,
1473                     cmd_f=cmd_f,
1474                     converter=converter,
1475                 )
1476                 lines = cmd_f.read().splitlines()[:-6]
1477
1478             assert "first-entry" in lines.pop()
1479             assert "update-encoding" in lines.pop()
1480             assert "Plain" in lines.pop()
1481             assert "part.html" in lines.pop()
1482             assert "toggle-unlink" in lines.pop()
1483             assert "move-up" in lines.pop()
1484             while True:
1485                 top = lines.pop()
1486                 if "move-up" not in top:
1487                     break
1488             assert "move-down" in top
1489             assert "HTML" in lines.pop()
1490             assert "logo.png" in lines.pop()
1491             assert "toggle-unlink" in lines.pop()
1492             assert "content-id" in lines.pop()
1493             assert "move-up" in lines.pop()
1494             while True:
1495                 top = lines.pop()
1496                 if "move-up" not in top:
1497                     break
1498             assert "move-down" in top
1499             assert "move-down" in lines.pop()
1500             assert "Logo" in lines.pop()
1501             assert "jump>2" in lines.pop()
1502             assert "jump>3" in lines.pop()
1503             assert "group-related" in lines.pop()
1504             assert "Related" in lines.pop()
1505             assert "jump>1" in lines.pop()
1506             assert "jump>2" in lines.pop()
1507             assert "group-alternative" in lines.pop()
1508             assert "Alternative" in lines.pop()
1509             assert "send-message" in lines.pop()
1510             assert len(lines) == 0
1511
1512         @pytest.mark.massage
1513         def test_mime_tree_nested_trees_does_not_break_positioning(
1514             self, mime_tree_nested
1515         ):
1516             def converter(draft_f, **kwargs):
1517                 return mime_tree_nested
1518
1519             with File() as draft_f, File() as cmd_f:
1520                 do_massage(
1521                     draft_f=draft_f,
1522                     cmd_f=cmd_f,
1523                     converter=converter,
1524                 )
1525                 lines = cmd_f.read().splitlines()
1526
1527             while "logo.png" not in lines.pop():
1528                 pass
1529             lines.pop()
1530             assert "content-id" in lines.pop()
1531             assert "move-up" in lines.pop()
1532             while True:
1533                 top = lines.pop()
1534                 if "move-up" not in top:
1535                     break
1536             assert "move-down" in top
1537             # Due to the nested trees, the number of descendents of the sibling
1538             # actually needs to be considered, not just the nieces. So to move
1539             # from position 1 to position 6, it only needs one <move-down>
1540             # because that jumps over the entire sibling tree. Thus what
1541             # follows next must not be another <move-down>
1542             assert "Logo" in lines.pop()
1543
1544         @pytest.mark.converter
1545         def test_converter_tree_basic(self, fakepath, const1, fakefilefactory):
1546             with fakefilefactory(fakepath, content=const1) as draft_f:
1547                 tree = convert_markdown_to_html(
1548                     draft_f, filefactory=fakefilefactory
1549                 )
1550
1551             assert tree.subtype == "alternative"
1552             assert len(tree.children) == 2
1553             assert tree.children[0].subtype == "plain"
1554             assert tree.children[0].path == draft_f.path
1555             assert tree.children[0].orig
1556             assert tree.children[1].subtype == "html"
1557             assert tree.children[1].path == fakepath.with_suffix(".html")
1558
1559         @pytest.mark.converter
1560         def test_converter_writes(
1561             self, fakepath, fakefilefactory, const1, monkeypatch
1562         ):
1563             with fakefilefactory(fakepath, content=const1) as draft_f:
1564                 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1565
1566             html = fakefilefactory.pop()
1567             assert fakepath.with_suffix(".html") == html[0]
1568             assert const1 in html[1].read()
1569             text = fakefilefactory.pop()
1570             assert fakepath == text[0]
1571             assert const1 == text[1].read()
1572
1573         @pytest.mark.imgproc
1574         def test_markdown_inline_image_processor(self):
1575             imgpath1 = "file:/path/to/image.png"
1576             imgpath2 = "file:///path/to/image.png?url=params"
1577             imgpath3 = "/path/to/image.png"
1578             text = f"""![inline local image]({imgpath1})
1579                        ![image inlined
1580                          with newline]({imgpath2})
1581                        ![image local path]({imgpath3})"""
1582             text, html, images, mdwn = markdown_with_inline_image_support(text)
1583
1584             # local paths have been normalised to URLs:
1585             imgpath3 = f"file://{imgpath3}"
1586
1587             assert 'src="cid:' in html
1588             assert "](cid:" in text
1589             assert len(images) == 3
1590             assert imgpath1 in images
1591             assert imgpath2 in images
1592             assert imgpath3 in images
1593             assert images[imgpath1].cid != images[imgpath2].cid
1594             assert images[imgpath1].cid != images[imgpath3].cid
1595             assert images[imgpath2].cid != images[imgpath3].cid
1596
1597         @pytest.mark.imgproc
1598         def test_markdown_inline_image_processor_title_to_desc(self, const1):
1599             imgpath = "file:///path/to/image.png"
1600             text = f'![inline local image]({imgpath} "{const1}")'
1601             text, html, images, mdwn = markdown_with_inline_image_support(text)
1602             assert images[imgpath].desc == const1
1603
1604         @pytest.mark.imgproc
1605         def test_markdown_inline_image_processor_alt_to_desc(self, const1):
1606             imgpath = "file:///path/to/image.png"
1607             text = f"![{const1}]({imgpath})"
1608             text, html, images, mdwn = markdown_with_inline_image_support(text)
1609             assert images[imgpath].desc == const1
1610
1611         @pytest.mark.imgproc
1612         def test_markdown_inline_image_processor_title_over_alt_desc(
1613             self, const1, const2
1614         ):
1615             imgpath = "file:///path/to/image.png"
1616             text = f'![{const1}]({imgpath} "{const2}")'
1617             text, html, images, mdwn = markdown_with_inline_image_support(text)
1618             assert images[imgpath].desc == const2
1619
1620         @pytest.mark.imgproc
1621         def test_markdown_inline_image_not_external(self):
1622             imgpath = "https://path/to/image.png"
1623             text = f"![inline image]({imgpath})"
1624             text, html, images, mdwn = markdown_with_inline_image_support(text)
1625
1626             assert 'src="cid:' not in html
1627             assert "](cid:" not in text
1628             assert len(images) == 0
1629
1630         @pytest.mark.imgproc
1631         def test_markdown_inline_image_local_file(self):
1632             imgpath = "/path/to/image.png"
1633             text = f"![inline image]({imgpath})"
1634             text, html, images, mdwn = markdown_with_inline_image_support(text)
1635
1636             for k, v in images.items():
1637                 assert k == f"file://{imgpath}"
1638                 break
1639
1640         @pytest.mark.imgproc
1641         def test_markdown_inline_image_expanduser(self):
1642             imgpath = pathlib.Path("~/image.png")
1643             text = f"![inline image]({imgpath})"
1644             text, html, images, mdwn = markdown_with_inline_image_support(text)
1645
1646             for k, v in images.items():
1647                 assert k == f"file://{imgpath.expanduser()}"
1648                 break
1649
1650         @pytest.fixture
1651         def test_png(self):
1652             return (
1653                 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1654                 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
1655             )
1656
1657         @pytest.mark.imgproc
1658         def test_markdown_inline_image_processor_base64(self, test_png):
1659             text = f"![1px white inlined]({test_png})"
1660             text, html, images, mdwn = markdown_with_inline_image_support(text)
1661
1662             assert 'src="cid:' in html
1663             assert "](cid:" in text
1664             assert len(images) == 1
1665             assert test_png in images
1666
1667         @pytest.mark.converter
1668         def test_converter_tree_inline_image_base64(
1669             self, test_png, fakefilefactory
1670         ):
1671             text = f"![inline base64 image]({test_png})"
1672             with fakefilefactory(content=text) as draft_f:
1673                 tree = convert_markdown_to_html(
1674                     draft_f,
1675                     filefactory=fakefilefactory,
1676                     related_to_html_only=False,
1677                 )
1678             assert tree.subtype == "relative"
1679             assert tree.children[0].subtype == "alternative"
1680             assert tree.children[1].subtype == "png"
1681             written = fakefilefactory.pop()
1682             assert tree.children[1].path == written[0]
1683             assert b"PNG" in written[1].read()
1684
1685         @pytest.mark.converter
1686         def test_converter_tree_inline_image_base64_related_to_html(
1687             self, test_png, fakefilefactory
1688         ):
1689             text = f"![inline base64 image]({test_png})"
1690             with fakefilefactory(content=text) as draft_f:
1691                 tree = convert_markdown_to_html(
1692                     draft_f,
1693                     filefactory=fakefilefactory,
1694                     related_to_html_only=True,
1695                 )
1696             assert tree.subtype == "alternative"
1697             assert tree.children[1].subtype == "relative"
1698             assert tree.children[1].children[1].subtype == "png"
1699             written = fakefilefactory.pop()
1700             assert tree.children[1].children[1].path == written[0]
1701             assert b"PNG" in written[1].read()
1702
1703         @pytest.mark.converter
1704         def test_converter_tree_inline_image_cid(
1705             self, const1, fakefilefactory
1706         ):
1707             text = f"![inline base64 image](cid:{const1})"
1708             with fakefilefactory(content=text) as draft_f:
1709                 tree = convert_markdown_to_html(
1710                     draft_f,
1711                     filefactory=fakefilefactory,
1712                     related_to_html_only=False,
1713                 )
1714             assert len(tree.children) == 2
1715             assert tree.children[0].cid != const1
1716             assert tree.children[0].type != "image"
1717             assert tree.children[1].cid != const1
1718             assert tree.children[1].type != "image"
1719
1720         @pytest.fixture
1721         def fakefilefactory(self):
1722             return FakeFileFactory()
1723
1724         @pytest.mark.imgcoll
1725         def test_inline_image_collection(
1726             self, test_png, const1, const2, fakefilefactory
1727         ):
1728             test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1729             relparts = collect_inline_images(
1730                 test_images, filefactory=fakefilefactory
1731             )
1732
1733             written = fakefilefactory.pop()
1734             assert b"PNG" in written[1].read()
1735
1736             assert relparts[0].subtype == "png"
1737             assert relparts[0].path == written[0]
1738             assert relparts[0].cid == const1
1739             assert const2 in relparts[0].desc
1740
1741         if _PYNLINER:
1742
1743             @pytest.mark.styling
1744             def test_apply_stylesheet(self):
1745                 html = "<p>Hello, world!</p>"
1746                 css = "p { color:red }"
1747                 out = apply_styling(html, css)
1748                 assert 'p style="color' in out
1749
1750             @pytest.mark.styling
1751             def test_apply_no_stylesheet(self, const1):
1752                 out = apply_styling(const1, None)
1753
1754             @pytest.mark.massage
1755             @pytest.mark.styling
1756             def test_massage_styling_to_converter(self):
1757                 css = "p { color:red }"
1758                 css_applied = []
1759
1760                 def converter(draft_f, css_f, **kwargs):
1761                     css = css_f.read()
1762                     css_applied.append(css)
1763                     return Part("text", "plain", draft_f.path, orig=True)
1764
1765                 with (
1766                     File() as draft_f,
1767                     File(mode="w") as cmd_f,
1768                     File(content=css) as css_f
1769                 ):
1770                     do_massage(
1771                         draft_f=draft_f,
1772                         cmd_f=cmd_f,
1773                         css_f=css_f,
1774                         converter=converter,
1775                     )
1776                 assert css_applied[0] == css
1777
1778             @pytest.mark.converter
1779             @pytest.mark.styling
1780             def test_converter_apply_styles(
1781                 self, const1, monkeypatch, fakepath, fakefilefactory
1782             ):
1783                 css = "p { color:red }"
1784                 with (
1785                     monkeypatch.context() as m,
1786                     fakefilefactory(fakepath, content=const1) as draft_f,
1787                     fakefilefactory(content=css) as css_f,
1788                 ):
1789                     m.setattr(
1790                         markdown.Markdown,
1791                         "convert",
1792                         lambda s, t: f"<p>{t}</p>",
1793                     )
1794                     convert_markdown_to_html(
1795                         draft_f, css_f=css_f, filefactory=fakefilefactory
1796                     )
1797                 assert re.search(
1798                     r"color:.*red",
1799                     fakefilefactory[fakepath.with_suffix(".html")].read(),
1800                 )
1801
1802         if _PYGMENTS_CSS:
1803
1804             @pytest.mark.styling
1805             def test_apply_stylesheet_pygments(self):
1806                 html = (
1807                     f'<div class="{_CODEHILITE_CLASS}">'
1808                     "<pre>def foo():\n    return</pre></div>"
1809                 )
1810                 out = apply_styling(html, _PYGMENTS_CSS)
1811                 assert f'{_CODEHILITE_CLASS}" style="' in out
1812
1813         @pytest.mark.sig
1814         def test_signature_extraction_no_signature(self, const1):
1815             assert (const1, None, None) == extract_signature(const1)
1816
1817         @pytest.mark.sig
1818         def test_signature_extraction_just_text(self, const1, const2):
1819             origtext, textsig, htmlsig = extract_signature(
1820                 f"{const1}{EMAIL_SIG_SEP}{const2}"
1821             )
1822             assert origtext == const1
1823             assert textsig == const2
1824             assert htmlsig is None
1825
1826         @pytest.mark.sig
1827         def test_signature_extraction_html(
1828             self, fakepath, fakefilefactory, const1, const2
1829         ):
1830             sigconst = "HTML signature from {path} but as a string"
1831             sig = f'<div id="signature">{sigconst.format(path=fakepath)}</div>'
1832
1833             sig_f = fakefilefactory(fakepath, content=sig)
1834
1835             origtext, textsig, htmlsig = extract_signature(
1836                 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER} {fakepath}\n{const2}",
1837                 filefactory=fakefilefactory,
1838             )
1839             assert origtext == const1
1840             assert textsig == const2
1841             assert htmlsig == sigconst.format(path=fakepath)
1842
1843         @pytest.mark.sig
1844         def test_signature_extraction_file_not_found(self, fakepath, const1):
1845             with pytest.raises(FileNotFoundError):
1846                 origtext, textsig, htmlsig = extract_signature(
1847                     f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER}{fakepath}\n{const1}"
1848                 )
1849
1850         @pytest.mark.imgproc
1851         def test_image_registry(self, const1):
1852             reg = ImageRegistry()
1853             cid = reg.register(const1)
1854             assert "@" in cid
1855             assert not cid.startswith("<")
1856             assert not cid.endswith(">")
1857             assert const1 in reg
1858
1859         @pytest.mark.imgproc
1860         def test_image_registry_file_uri(self, const1):
1861             reg = ImageRegistry()
1862             reg.register("/some/path")
1863             for path in reg:
1864                 assert path.startswith("file://")
1865                 break
1866
1867         @pytest.mark.converter
1868         @pytest.mark.sig
1869         def test_converter_signature_handling(
1870             self, fakepath, fakefilefactory, monkeypatch
1871         ):
1872             mailparts = (
1873                 "This is the mail body\n",
1874                 f"{EMAIL_SIG_SEP}",
1875                 "This is a plain-text signature only",
1876             )
1877
1878             with (
1879                 fakefilefactory(
1880                     fakepath, content="".join(mailparts)
1881                 ) as draft_f,
1882                 monkeypatch.context() as m,
1883             ):
1884                 m.setattr(markdown.Markdown, "convert", lambda s, t: t)
1885                 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1886
1887             soup = bs4.BeautifulSoup(
1888                 fakefilefactory[fakepath.with_suffix(".html")].read(),
1889                 "html.parser",
1890             )
1891             body = soup.body.contents
1892
1893             assert mailparts[0] in body.pop(0)
1894
1895             sig = soup.select_one("#signature")
1896             assert sig == body.pop(0)
1897
1898             sep = sig.select_one("span.sig_separator")
1899             assert sep == sig.contents[0]
1900             assert f"\n{sep.text}\n" == EMAIL_SIG_SEP
1901
1902             assert mailparts[2] in sig.contents[1]
1903
1904         @pytest.mark.converter
1905         @pytest.mark.sig
1906         def test_converter_signature_handling_htmlsig(
1907             self, fakepath, fakepath2, fakefilefactory, monkeypatch
1908         ):
1909             mailparts = (
1910                 "This is the mail body",
1911                 f"{EMAIL_SIG_SEP}",
1912                 f"{HTML_SIG_MARKER}{fakepath2}\n",
1913                 "This is the plain-text version",
1914             )
1915             htmlsig = "HTML Signature from {path} but as a string"
1916             html = (
1917                 f'<div id="signature"><p>{htmlsig.format(path=fakepath2)}</p></div>'
1918             )
1919
1920             sig_f = fakefilefactory(fakepath2, content=html)
1921
1922             def mdwn_fn(t):
1923                 return t.upper()
1924
1925             with (
1926                 fakefilefactory(
1927                     fakepath, content="".join(mailparts)
1928                 ) as draft_f,
1929                 monkeypatch.context() as m,
1930             ):
1931                 m.setattr(
1932                     markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1933                 )
1934                 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1935
1936             soup = bs4.BeautifulSoup(
1937                 fakefilefactory[fakepath.with_suffix(".html")].read(),
1938                 "html.parser",
1939             )
1940             sig = soup.select_one("#signature")
1941             sig.span.extract()
1942
1943             assert HTML_SIG_MARKER not in sig.text
1944             assert htmlsig.format(path=fakepath2) == sig.text.strip()
1945
1946             plaintext = fakefilefactory[fakepath].read()
1947             assert plaintext.endswith(EMAIL_SIG_SEP + mailparts[-1])
1948
1949         @pytest.mark.converter
1950         @pytest.mark.sig
1951         def test_converter_signature_handling_htmlsig_with_image(
1952             self, fakepath, fakepath2, fakefilefactory, monkeypatch, test_png
1953         ):
1954             mailparts = (
1955                 "This is the mail body",
1956                 f"{EMAIL_SIG_SEP}",
1957                 f"{HTML_SIG_MARKER}{fakepath2}\n",
1958                 "This is the plain-text version",
1959             )
1960             htmlsig = (
1961                 "HTML Signature from {path} with image\n"
1962                 f'<img src="{test_png}">\n'
1963             )
1964             html = (
1965                 f'<div id="signature">{htmlsig.format(path=fakepath2)}</div>'
1966             )
1967
1968             sig_f = fakefilefactory(fakepath2, content=html)
1969
1970             def mdwn_fn(t):
1971                 return t.upper()
1972
1973             with (
1974                 fakefilefactory(
1975                     fakepath, content="".join(mailparts)
1976                 ) as draft_f,
1977                 monkeypatch.context() as m,
1978             ):
1979                 m.setattr(
1980                     markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1981                 )
1982                 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1983
1984             assert fakefilefactory.pop()[0].suffix == ".png"
1985
1986             soup = bs4.BeautifulSoup(
1987                 fakefilefactory[fakepath.with_suffix(".html")].read(),
1988                 "html.parser",
1989             )
1990             assert soup.img.attrs["src"].startswith("cid:")
1991
1992         @pytest.mark.converter
1993         @pytest.mark.sig
1994         def test_converter_signature_handling_textsig_with_image(
1995             self, fakepath, fakefilefactory, test_png
1996         ):
1997             mailparts = (
1998                 "This is the mail body",
1999                 f"{EMAIL_SIG_SEP}",
2000                 "This is the plain-text version with image\n",
2001                 f"![Inline]({test_png})",
2002             )
2003             with (
2004                 fakefilefactory(
2005                     fakepath, content="".join(mailparts)
2006                 ) as draft_f,
2007             ):
2008                 tree = convert_markdown_to_html(
2009                     draft_f, filefactory=fakefilefactory
2010                 )
2011
2012             assert tree.subtype == "relative"
2013             assert tree.children[0].subtype == "alternative"
2014             assert tree.children[1].subtype == "png"
2015             written = fakefilefactory.pop()
2016             assert tree.children[1].path == written[0]
2017             assert written[1].read() == request.urlopen(test_png).read()
2018
2019         @pytest.mark.converter
2020         def test_converter_attribution_to_admonition(
2021             self, fakepath, fakefilefactory
2022         ):
2023             mailparts = (
2024                 "Regarding whatever",
2025                 "> blockquote line1",
2026                 "> blockquote line2",
2027                 "> ",
2028                 "> new para with **bold** text",
2029             )
2030             with fakefilefactory(
2031                 fakepath, content="\n".join(mailparts)
2032             ) as draft_f:
2033                 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2034
2035             soup = bs4.BeautifulSoup(
2036                 fakefilefactory[fakepath.with_suffix(".html")].read(),
2037                 "html.parser",
2038             )
2039             quote = soup.select_one("div.admonition.quote")
2040             assert quote
2041             assert (
2042                 soup.select_one("p.admonition-title").extract().text.strip()
2043                 == mailparts[0]
2044             )
2045
2046             p = quote.p.extract()
2047             assert p.text.strip() == "\n".join(p[2:] for p in mailparts[1:3])
2048
2049             p = quote.p.extract()
2050             assert p.contents[1].name == "strong"
2051
2052         @pytest.mark.converter
2053         def test_converter_attribution_to_admonition_with_blockquote(
2054             self, fakepath, fakefilefactory
2055         ):
2056             mailparts = (
2057                 "Regarding whatever",
2058                 "> blockquote line1",
2059                 "> blockquote line2",
2060                 "> ",
2061                 "> new para with **bold** text",
2062             )
2063             with fakefilefactory(
2064                 fakepath, content="\n".join(mailparts)
2065             ) as draft_f:
2066                 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2067
2068             soup = bs4.BeautifulSoup(
2069                 fakefilefactory[fakepath.with_suffix(".html")].read(),
2070                 "html.parser",
2071             )
2072             quote = soup.select_one("div.admonition.quote")
2073             assert quote.blockquote
2074
2075         @pytest.mark.converter
2076         def test_converter_attribution_to_admonition_multiple(
2077             self, fakepath, fakefilefactory
2078         ):
2079             mailparts = (
2080                 "Regarding whatever",
2081                 "> blockquote line1",
2082                 "> blockquote line2",
2083                 "",
2084                 "Normal text",
2085                 "",
2086                 "> continued emailquote",
2087                 "",
2088                 "Another email-quote",
2089                 "> something",
2090             )
2091             with fakefilefactory(
2092                 fakepath, content="\n".join(mailparts)
2093             ) as draft_f:
2094                 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2095
2096             soup = bs4.BeautifulSoup(
2097                 fakefilefactory[fakepath.with_suffix(".html")].read(),
2098                 "html.parser",
2099             )
2100             quote = soup.select_one("div.admonition.quote.continued").extract()
2101             assert quote
2102             assert (
2103                 quote.select_one("p.admonition-title").extract().text.strip()
2104                 == mailparts[0]
2105             )
2106
2107             p = quote.p.extract()
2108             assert p
2109
2110             quote = soup.select_one("div.admonition.quote.continued").extract()
2111             assert quote
2112             assert (
2113                 quote.select_one("p.admonition-title").extract().text.strip()
2114                 == mailparts[-2]
2115             )
2116
2117         @pytest.mark.fileio
2118         def test_file_class_contextmanager(self, const1, monkeypatch):
2119             state = dict(o=False, c=False)
2120
2121             def fn(t):
2122                 state[t] = True
2123
2124             with monkeypatch.context() as m:
2125                 m.setattr(File, "open", lambda s: fn("o"))
2126                 m.setattr(File, "close", lambda s: fn("c"))
2127                 with File() as f:
2128                     assert state["o"]
2129                     assert not state["c"]
2130             assert state["c"]
2131
2132         @pytest.mark.fileio
2133         def test_file_class_no_path(self, const1):
2134             with File(mode="w+") as f:
2135                 f.write(const1, cache=False)
2136                 assert f.read(cache=False) == const1
2137
2138         @pytest.mark.fileio
2139         def test_file_class_path(self, const1, tmp_path):
2140             with File(tmp_path / "file", mode="w+") as f:
2141                 f.write(const1, cache=False)
2142                 assert f.read(cache=False) == const1
2143
2144         @pytest.mark.fileio
2145         def test_file_class_path_no_exists(self, fakepath):
2146             with pytest.raises(FileNotFoundError):
2147                 File(fakepath, mode="r").open()
2148
2149         @pytest.mark.fileio
2150         def test_file_class_cache(self, tmp_path, const1, const2):
2151             path = tmp_path / "file"
2152             file = File(path, mode="w+")
2153             with file as f:
2154                 f.write(const1, cache=True)
2155             with open(path, mode="w") as f:
2156                 f.write(const2)
2157             with file as f:
2158                 assert f.read(cache=True) == const1
2159
2160         @pytest.mark.fileio
2161         def test_file_class_cache_init(self, const1):
2162             file = File(path=None, mode="r", content=const1)
2163             with file as f:
2164                 assert f.read() == const1
2165
2166         @pytest.mark.fileio
2167         def test_file_class_content_or_path(self, fakepath, const1):
2168             with pytest.raises(RuntimeError):
2169                 file = File(path=fakepath, content=const1)
2170
2171         @pytest.mark.fileio
2172         def test_file_class_content_needs_read(self, const1):
2173             with pytest.raises(RuntimeError):
2174                 file = File(mode="w", content=const1)
2175
2176         @pytest.mark.fileio
2177         def test_file_class_write_persists_close(self, const1):
2178             f = File(mode="w+")
2179             with f:
2180                 f.write(const1)
2181             with f:
2182                 assert f.read() == const1
2183
2184         @pytest.mark.fileio
2185         def test_file_class_write_resets_read_cache(self, const1, const2):
2186             with File(mode="w+", content=const1) as f:
2187                 assert f.read() == const1
2188                 f.write(const2)
2189                 assert f.read() == const2
2190
2191         @pytest.mark.fileio
2192         def test_file_factory(self):
2193             fact = FileFactory()
2194             f = fact()
2195             assert isinstance(f, File)
2196             assert len(fact) == 1
2197             assert f in fact
2198             assert f == fact[0]
2199
2200         @pytest.mark.fileio
2201         def test_fake_file_factory(self, fakepath, fakefilefactory):
2202             fact = FakeFileFactory()
2203             f = fakefilefactory(fakepath)
2204             assert f.path == fakepath
2205             assert f == fakefilefactory[fakepath]
2206
2207         @pytest.mark.fileio
2208         def test_fake_file_factory_path_persistence(
2209             self, fakepath, fakefilefactory
2210         ):
2211             f1 = fakefilefactory(fakepath)
2212             assert f1 == fakefilefactory(fakepath)
2213
2214 except ImportError:
2215     pass