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

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