All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
3 # NeoMutt helper script to create multipart/* emails with Markdown → HTML
4 # alternative conversion, and handling of inline images, using NeoMutt's
5 # ability to manually craft MIME trees, but automating this process.
8 # neomuttrc (needs to be a single line):
9 # set my_mdwn_extensions="extra,admonition,codehilite,sane_lists,smarty"
11 # <enter-command> source '$my_confdir/buildmimetree.py \
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"
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
25 # - python3-beautifulsoup4
28 # - Pynliner, provides --css-file and thus inline styling of HTML output
29 # - Pygments, then syntax highlighting for fenced code is enabled
32 # pytest -x buildmimetree.py
35 # https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
37 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
38 # Released under the GPL-2+ licence, just like Mutt itself.
50 import xml.etree.ElementTree as etree
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
62 def parse_cli_args(*args, **kwargs):
63 parser = argparse.ArgumentParser(
65 "NeoMutt helper to turn text/markdown email parts "
66 "into full-fledged MIME trees"
70 "Copyright © 2023 martin f. krafft <madduck@madduck.net>.\n"
71 "Released under the MIT licence"
76 metavar="EXT[,EXT[,EXT]]",
79 help="Markdown extension to use (comma-separated list)",
88 help="CSS file to merge with the final HTML",
91 parser.set_defaults(css_file=None)
94 "--related-to-html-only",
96 help="Make related content be sibling to HTML parts only",
99 def positive_integer(value):
107 raise ValueError("Must be a positive integer")
110 "--max-number-other-attachments",
112 type=positive_integer,
114 help="Maximum number of other attachments to expect",
121 help="Only build, don't send the message",
128 help="Specify temporary directory to use for attachments",
134 help="Turn on debug logging of commands generated to stderr",
140 help="Turn on debugging to stderr of the MIME tree walk",
147 help="Write the generated HTML to the file",
150 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
151 massage_p = subp.add_parser(
152 "massage", help="Massaging phase (internal use)"
155 massage_p.add_argument(
156 "--write-commands-to",
162 help="Temporary file path to write commands to",
165 massage_p.add_argument(
169 help="If provided, the script is invoked as editor on the mail draft",
172 return parser.parse_args(*args, **kwargs)
175 # [ FILE I/O HANDLING ] #######################################################
184 def __init__(self, path=None, mode="r", content=None, **kwargs):
187 raise RuntimeError("Cannot specify path and content for File")
190 path if isinstance(path, pathlib.Path) else pathlib.Path(path)
195 if content and not re.search(r"[r+]", mode):
196 raise RuntimeError("Cannot specify content without read mode")
199 File.Op.R: [content] if content else [],
204 self._kwargs = kwargs
209 self._file = open(self._path, self._mode, **self._kwargs)
210 elif "b" in self._mode:
211 self._file = io.BytesIO()
213 self._file = io.StringIO()
219 def __exit__(self, exc_type, exc_val, exc_tb):
225 self._cache[File.Op.R] = self._cache[File.Op.W]
228 def _get_cache(self, op):
229 return (b"" if "b" in self._mode else "").join(self._cache[op])
231 def _add_to_cache(self, op, s):
232 self._cache[op].append(s)
234 def read(self, *, cache=True):
235 if cache and self._cache[File.Op.R]:
236 return self._get_cache(File.Op.R)
238 if self._lastop == File.Op.W:
241 except io.UnsupportedOperation:
244 self._lastop = File.Op.R
247 self._add_to_cache(File.Op.R, self._file.read())
248 return self._get_cache(File.Op.R)
250 return self._file.read()
252 def write(self, s, *, cache=True):
254 if self._lastop == File.Op.R:
257 except io.UnsupportedOperation:
261 self._add_to_cache(File.Op.W, s)
263 self._cache[File.Op.R] = self._cache[File.Op.W]
265 written = self._file.write(s)
267 self._lastop = File.Op.W
270 path = property(lambda s: s._path)
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}>"
284 def __call__(self, path=None, mode="r", content=None, **kwargs):
285 f = File(path, mode, content, **kwargs)
286 self._files.append(f)
290 return self._files.__len__()
292 def pop(self, idx=-1):
293 return self._files.pop(idx)
295 def __getitem__(self, idx):
296 return self._files.__getitem__(idx)
298 def __contains__(self, f):
299 return self._files.__contains__(f)
302 class FakeFileFactory(FileFactory):
305 self._paths2files = OrderedDict()
307 def __call__(self, path=None, mode="r", content=None, **kwargs):
308 if path in self._paths2files:
309 return self._paths2files[path]
311 f = super().__call__(None, mode, content, **kwargs)
312 self._paths2files[path] = f
316 class FakeFile(File):
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.
322 f.__class__ = FakeFile
325 def __getitem__(self, path):
326 return self._paths2files.__getitem__(path)
328 def get(self, path, default):
329 return self._paths2files.get(path, default)
331 def pop(self, last=True):
332 return self._paths2files.popitem(last)
336 f"<FakeFileFactory nfiles={len(self._files)} "
337 f"paths={len(self._paths2files)}>"
341 # [ IMAGE HANDLING ] ##########################################################
344 InlineImageInfo = namedtuple(
345 "InlineImageInfo", ["cid", "desc"], defaults=[None]
351 self._images = OrderedDict()
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)
363 return self._images.__iter__()
365 def __getitem__(self, idx):
366 return self._images.__getitem__(idx)
369 return self._images.__len__()
372 return self._images.items()
375 return f"<ImageRegistry(items={len(self._images)})>"
378 return self._images.__str__()
381 class InlineImageExtension(Extension):
382 class RelatedImageInlineProcessor(ImageInlineProcessor):
383 def __init__(self, re, md, registry):
384 super().__init__(re, md)
385 self._registry = registry
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(
395 el.attrib.get("title", el.attrib.get("alt")),
397 el.attrib["src"] = f"cid:{cid}"
398 return el, start, end
400 def __init__(self, registry):
402 self._image_registry = registry
404 INLINE_PATTERN_NAME = "image_link"
406 def extendMarkdown(self, md):
407 md.registerExtension(self)
408 inline_image_proc = self.RelatedImageInlineProcessor(
409 IMAGE_LINK_RE, md, self._image_registry
411 md.inlinePatterns.register(
412 inline_image_proc, InlineImageExtension.INLINE_PATTERN_NAME, 150
416 def markdown_with_inline_image_support(
422 extension_configs=None,
425 image_registry if image_registry is not None else ImageRegistry()
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
434 htmltext = mdwn.convert(text)
436 def replace_image_with_cid(matchobj):
437 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
439 return f"(cid:{registry[m].cid}"
440 return matchobj.group(0)
442 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
443 return text, htmltext, registry, mdwn
446 # [ CSS STYLING ] #############################################################
458 from pygments.formatters import get_formatter_by_name
460 _CODEHILITE_CLASS = "codehilite"
462 _PYGMENTS_CSS = get_formatter_by_name(
463 "html", style="default"
464 ).get_style_defs(f".{_CODEHILITE_CLASS}")
470 def apply_styling(html, css):
474 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
479 # [ QUOTE HANDLING ] ##########################################################
482 class QuoteToAdmonitionExtension(Extension):
483 class EmailQuoteBlockProcessor(BlockProcessor):
484 RE = re.compile(r"(?:^|\n)>\s*(.*)")
486 def __init__(self, parser):
487 super().__init__(parser)
489 self._disable = False
491 def test(self, parent, blocks):
495 if markdown.util.nearing_recursion_limit():
498 lines = blocks.splitlines()
503 elif not self.RE.search(lines[0]):
506 return len(lines) > 0
508 elif not self.RE.search(lines[0]) and self.RE.search(lines[1]):
511 elif self._title and self.RE.search(lines[1]):
516 def run(self, parent, blocks):
517 quotelines = blocks.pop(0).splitlines()
519 cont = bool(self._title)
520 if not self.RE.search(quotelines[0]):
521 self._title = quotelines.pop(0)
523 admonition = etree.SubElement(parent, "div")
525 "class", f"admonition quote{' continued' if cont else ''}"
527 self.parser.parseChunk(admonition, self._title)
529 admonition[0].set("class", "admonition-title")
531 self.parser.parseChunk(
532 admonition, "\n".join(quotelines)
539 self._disable = False
542 def clean(klass, line):
543 m = klass.RE.match(line)
544 return m.group(1) if m else line
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)
552 # [ PARTS GENERATION ] ########################################################
558 ["type", "subtype", "path", "desc", "cid", "orig"],
559 defaults=[None, None, False],
563 ret = f"<{self.type}/{self.subtype}>"
565 ret = f"{ret} cid:{self.cid}"
567 ret = f"{ret} ORIGINAL"
572 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
575 return f"<multipart/{self.subtype}> children={len(self.children)}"
578 return hash(str(self.subtype) + "".join(str(self.children)))
581 def collect_inline_images(
582 image_registry, *, tempdir=None, filefactory=FileFactory()
585 for path, info in image_registry.items():
586 if path.startswith("cid:"):
589 data = request.urlopen(path)
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])
596 with filefactory(path, "w+b") as out_f:
597 out_f.write(data.read())
599 # filewriter_fn(path, data.read(), "w+b")
602 f'Inline image: "{info.desc}"'
604 else f"Inline image {str(len(relparts)+1)}"
607 Part(*mimetype.split("/"), path, cid=info.cid, desc=desc)
613 EMAIL_SIG_SEP = "\n-- \n"
614 HTML_SIG_MARKER = "=htmlsig "
617 def make_html_doc(body, sig=None):
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
632 f'{ret}<div id="signature"><span class="sig_separator">{EMAIL_SIG_SEP.strip(nl)}</span>\n' # noqa: E501
637 return f"{ret}\n </body>\n</html>"
640 def make_text_mail(text, sig=None):
641 return EMAIL_SIG_SEP.join((text, sig)) if sig else text
644 def extract_signature(text, *, filefactory=FileFactory()):
645 parts = text.split(EMAIL_SIG_SEP, 1)
647 return text, None, None
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)
654 with filefactory(path.expanduser()) as sig_f:
655 sig_input = sig_f.read()
657 soup = bs4.BeautifulSoup(sig_input, "html.parser")
659 style = str(soup.style.extract()) if soup.style else ""
660 for sig_selector in (
670 sig = soup.select_one(sig_selector)
675 return parts[0], textsig, style + sig_input
677 if sig.attrs.get("id") == "signature":
678 sig = "".join(str(c) for c in sig.children)
680 return parts[0], textsig, style + str(sig)
682 return parts[0], parts[1], None
685 def convert_markdown_to_html(
688 related_to_html_only=False,
691 filefactory=FileFactory(),
694 extension_configs=None,
696 # TODO extension_configs need to be handled differently
697 extension_configs = extension_configs or {}
698 extension_configs.setdefault("pymdownx.highlight", {})[
700 ] = _CODEHILITE_CLASS
702 extensions = extensions or []
703 extensions.append(QuoteToAdmonitionExtension())
705 draft = draft_f.read()
706 origtext, textsig, htmlsig = extract_signature(
707 draft, filefactory=filefactory
715 ) = markdown_with_inline_image_support(
716 origtext, extensions=extensions, extension_configs=extension_configs
721 # TODO: decide what to do if there is no plain-text version
722 raise NotImplementedError("HTML signature but no text alternative")
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}"
739 ) = markdown_with_inline_image_support(
741 extensions=extensions,
742 extension_configs=extension_configs,
743 image_registry=image_registry,
747 origtext = make_text_mail(origtext, textsig)
748 draft_f.write(origtext)
750 "text", "plain", draft_f.path, "Plain-text version", orig=True
753 htmltext = make_html_doc(htmltext, htmlsig)
754 htmltext = apply_styling(htmltext, css_f.read() if css_f else None)
757 htmlpath = draft_f.path.with_suffix(".html")
759 htmlpath = pathlib.Path(
760 tempfile.mkstemp(suffix=".html", dir=tempdir)[1]
763 htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
765 out_f.write(htmltext)
766 htmlpart = Part("text", "html", htmlpath, "HTML version")
769 htmldump_f.write(htmltext)
771 imgparts = collect_inline_images(
772 image_registry, tempdir=tempdir, filefactory=filefactory
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
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"
786 "alternative", [textpart, htmlpart], "Group of alternative content"
790 # If there are inline image part, they will be siblings to the
791 # multipart/alternative tree within a multipart/related part
793 "alternative", [textpart, htmlpart], "Group of alternative content"
797 "relative", [altpart] + imgparts, "Group of related content"
803 class MIMETreeDFWalker:
804 def __init__(self, *, visitor_fn=None, debug=False):
805 self._visitor_fn = visitor_fn or self._echovisit
808 def _echovisit(self, node, ancestry, debugprint):
809 debugprint(f"node={node} ancestry={ancestry}")
811 def walk(self, root, *, visitor_fn=None):
813 Recursive function to implement a depth-dirst walk of the MIME-tree
816 if isinstance(root, list):
818 root = Multipart("mixed", children=root)
826 visitor_fn=visitor_fn or self._visitor_fn,
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
838 lead = f"{'│ '*len(ancestry)}"
839 if isinstance(node, Multipart):
841 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
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:
852 descendents=descendents,
853 visitor_fn=visitor_fn,
855 assert ancestry.pop() == node
856 sibling_descendents = descendents
857 descendents.extend(node.children)
860 self.debugprint(f"{lead}├{node}")
861 sibling_descendents = descendents
863 if False and ancestry:
864 self.debugprint(lead[:-1] + " │")
868 node, ancestry, sibling_descendents, debugprint=self.debugprint
871 def debugprint(self, s, **kwargs):
873 print(s, file=sys.stderr, **kwargs)
876 # [ RUN MODES ] ###############################################################
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"
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.
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.
894 def __init__(self, out_f=sys.stdout, *, debug=False):
895 self._cmd1, self._push, self._cmd2 = [], [], []
907 s = s.replace('"', r"\"")
910 self._push.insert(0, s)
914 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
916 self._cmd1, self._push, self._cmd2 = [], [], []
918 def debugprint(self, s, **kwargs):
920 print(s, file=sys.stderr, **kwargs)
928 debug_commands=False,
930 temppath = temppath or pathlib.Path(
931 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
933 cmds = MuttCommands(out_f, debug=debug_commands)
935 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
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>")
953 converter=convert_markdown_to_html,
954 related_to_html_only=True,
956 max_other_attachments=20,
958 debug_commands=False,
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.
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
969 cmds = MuttCommands(cmd_f, debug=debug_commands)
971 extensions = extensions.split(",") if extensions else []
975 htmldump_f=htmldump_f,
976 related_to_html_only=related_to_html_only,
978 extensions=extensions,
981 mimetree = MIMETreeDFWalker(debug=debug_walk)
983 state = dict(pos=1, tags={}, parts=1)
985 def visitor_fn(item, ancestry, descendents, *, debugprint=None):
987 Visitor function called for every node (part) of the MIME tree,
988 depth-first, and responsible for telling NeoMutt how to assemble
991 KILL_LINE = r"\Ca\Ck"
993 if isinstance(item, Part):
994 # We've hit a leaf-node, i.e. an alternative or a related part
995 # with actual content.
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>")
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
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>")
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
1021 state["pos"] = state["parts"]
1023 # If the item (including the original) comes with additional
1024 # information, then we might just as well update the NeoMutt
1027 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
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>")
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
1050 for i in range(1, state["pos"] - len(descendents)):
1051 cmds.push("<move-down>")
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>")
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>")
1069 raise NotImplementedError(
1070 f"Handling of multipart/{item.subtype} is not implemented"
1073 state["pos"] -= n_tags - 1
1077 # We should never get here
1078 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
1080 # If the item has a description, we might just as well add it
1082 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
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"])
1090 lead = "│ " * (len(ancestry) + 1) + "* "
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']}"
1101 # Let's walk the tree and visit every node with our fancy visitor
1103 mimetree.walk(tree, visitor_fn=visitor_fn)
1106 cmds.push("<send-message>")
1108 # Finally, cleanup. Since we're responsible for removing the temporary
1109 # file, how's this for a little hack?
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")
1123 # [ CLI ENTRY ] ###############################################################
1125 if __name__ == "__main__":
1126 args = parse_cli_args()
1128 if args.mode is None:
1130 tempdir=args.tempdir,
1131 debug_commands=args.debug_commands,
1134 elif args.mode == "massage":
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,
1144 extensions=args.extensions,
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,
1156 # [ TESTS ] ###################################################################
1164 return "Curvature Vest Usher Dividing+T#iceps Senior"
1168 return "Habitant Celestial 2litzy Resurf/ce Headpiece Harmonics"
1172 return pathlib.Path("/does/not/exist")
1175 def fakepath2(self):
1176 return pathlib.Path("/does/not/exist/either")
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
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)
1188 captured = capsys.readouterr()
1189 assert captured.out == "\n".join((const1, const2, ""))
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)
1198 captured = capsys.readouterr()
1201 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
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}"')
1209 captured = capsys.readouterr()
1210 assert f'"\\"{const1}\\""' in captured.out
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"]
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])
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]
1236 def mime_tree_related_to_alternative(self):
1250 Part("text", "html", "part.html", desc="HTML"),
1255 "text", "png", "logo.png", cid="logo.png", desc="Logo"
1262 def mime_tree_related_to_html(self):
1276 Part("text", "html", "part.html", desc="HTML"),
1292 def mime_tree_nested(self):
1313 desc="Nested plain",
1322 desc="Nested alternative",
1338 @pytest.mark.treewalk
1339 def test_MIMETreeDFWalker_depth_first_walk(
1340 self, mime_tree_related_to_alternative
1342 mimetree = MIMETreeDFWalker()
1346 def visitor_fn(item, ancestry, descendents, debugprint):
1347 items.append((item, len(ancestry), len(descendents)))
1350 mime_tree_related_to_alternative, visitor_fn=visitor_fn
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
1369 @pytest.mark.treewalk
1370 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
1371 mimetree = MIMETreeDFWalker()
1374 def visitor_fn(item, ancestry, descendents, debugprint):
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"
1383 @pytest.mark.treewalk
1384 def test_MIMETreeDFWalker_visitor_in_constructor(
1385 self, mime_tree_related_to_alternative
1389 def visitor_fn(item, ancestry, descendents, debugprint):
1392 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
1393 mimetree.walk(mime_tree_related_to_alternative)
1394 assert len(items) == 5
1397 def string_io(self, const1, text=None):
1398 return StringIO(text or const1)
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)
1405 with File() as draft_f, File() as cmd_f:
1409 converter=converter,
1411 lines = cmd_f.read().splitlines()
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)
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
1430 with File() as draft_f, File() as cmd_f:
1434 max_other_attachments=max_attachments,
1435 converter=converter,
1437 lines = cmd_f.read().splitlines()[:-6]
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
1466 @pytest.mark.massage
1467 def test_mime_tree_relative_within_alternative(
1468 self, mime_tree_related_to_html
1470 def converter(draft_f, **kwargs):
1471 return mime_tree_related_to_html
1473 with File() as draft_f, File() as cmd_f:
1477 converter=converter,
1479 lines = cmd_f.read().splitlines()[:-6]
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()
1489 if "move-up" not in top:
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()
1499 if "move-up" not in top:
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
1515 @pytest.mark.massage
1516 def test_mime_tree_nested_trees_does_not_break_positioning(
1517 self, mime_tree_nested
1519 def converter(draft_f, **kwargs):
1520 return mime_tree_nested
1522 with File() as draft_f, File() as cmd_f:
1526 converter=converter,
1528 lines = cmd_f.read().splitlines()
1530 while "logo.png" not in lines.pop():
1533 assert "content-id" in lines.pop()
1534 assert "move-up" in lines.pop()
1537 if "move-up" not in top:
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()
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
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")
1562 @pytest.mark.converter
1563 def test_converter_writes(
1564 self, fakepath, fakefilefactory, const1, monkeypatch
1566 with fakefilefactory(fakepath, content=const1) as draft_f:
1567 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
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()
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})
1583 with newline]({imgpath2})
1584 ![image local path]({imgpath3})"""
1585 text, html, images, mdwn = markdown_with_inline_image_support(text)
1587 # local paths have been normalised to URLs:
1588 imgpath3 = f"file://{imgpath3}"
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
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
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
1614 @pytest.mark.imgproc
1615 def test_markdown_inline_image_processor_title_over_alt_desc(
1616 self, const1, const2
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
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)
1629 assert 'src="cid:' not in html
1630 assert "](cid:" not in text
1631 assert len(images) == 0
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)
1639 for k, v in images.items():
1640 assert k == f"file://{imgpath}"
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)
1649 for k, v in images.items():
1650 assert k == f"file://{imgpath.expanduser()}"
1656 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1657 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
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)
1665 assert 'src="cid:' in html
1666 assert "](cid:" in text
1667 assert len(images) == 1
1668 assert test_png in images
1670 @pytest.mark.converter
1671 def test_converter_tree_inline_image_base64(
1672 self, test_png, fakefilefactory
1674 text = f"![inline base64 image]({test_png})"
1675 with fakefilefactory(content=text) as draft_f:
1676 tree = convert_markdown_to_html(
1678 filefactory=fakefilefactory,
1679 related_to_html_only=False,
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()
1688 @pytest.mark.converter
1689 def test_converter_tree_inline_image_base64_related_to_html(
1690 self, test_png, fakefilefactory
1692 text = f"![inline base64 image]({test_png})"
1693 with fakefilefactory(content=text) as draft_f:
1694 tree = convert_markdown_to_html(
1696 filefactory=fakefilefactory,
1697 related_to_html_only=True,
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()
1706 @pytest.mark.converter
1707 def test_converter_tree_inline_image_cid(
1708 self, const1, fakefilefactory
1710 text = f"![inline base64 image](cid:{const1})"
1711 with fakefilefactory(content=text) as draft_f:
1712 tree = convert_markdown_to_html(
1714 filefactory=fakefilefactory,
1715 related_to_html_only=False,
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"
1724 def fakefilefactory(self):
1725 return FakeFileFactory()
1727 @pytest.mark.imgcoll
1728 def test_inline_image_collection(
1729 self, test_png, const1, const2, fakefilefactory
1731 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1732 relparts = collect_inline_images(
1733 test_images, filefactory=fakefilefactory
1736 written = fakefilefactory.pop()
1737 assert b"PNG" in written[1].read()
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
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
1753 @pytest.mark.styling
1754 def test_apply_no_stylesheet(self, const1):
1755 out = apply_styling(const1, None)
1757 @pytest.mark.massage
1758 @pytest.mark.styling
1759 def test_massage_styling_to_converter(self):
1760 css = "p { color:red }"
1763 def converter(draft_f, css_f, **kwargs):
1765 css_applied.append(css)
1766 return Part("text", "plain", draft_f.path, orig=True)
1770 File(mode="w") as cmd_f,
1771 File(content=css) as css_f
1777 converter=converter,
1779 assert css_applied[0] == css
1781 @pytest.mark.converter
1782 @pytest.mark.styling
1783 def test_converter_apply_styles(
1784 self, const1, monkeypatch, fakepath, fakefilefactory
1786 css = "p { color:red }"
1788 monkeypatch.context() as m,
1789 fakefilefactory(fakepath, content=const1) as draft_f,
1790 fakefilefactory(content=css) as css_f,
1795 lambda s, t: f"<p>{t}</p>",
1797 convert_markdown_to_html(
1798 draft_f, css_f=css_f, filefactory=fakefilefactory
1802 fakefilefactory[fakepath.with_suffix(".html")].read(),
1807 @pytest.mark.styling
1808 def test_apply_stylesheet_pygments(self):
1810 f'<div class="{_CODEHILITE_CLASS}">'
1811 "<pre>def foo():\n return</pre></div>"
1813 out = apply_styling(html, _PYGMENTS_CSS)
1814 assert f'{_CODEHILITE_CLASS}" style="' in out
1817 def test_signature_extraction_no_signature(self, const1):
1818 assert (const1, None, None) == extract_signature(const1)
1821 def test_signature_extraction_just_text(self, const1, const2):
1822 origtext, textsig, htmlsig = extract_signature(
1823 f"{const1}{EMAIL_SIG_SEP}{const2}"
1825 assert origtext == const1
1826 assert textsig == const2
1827 assert htmlsig is None
1830 def test_signature_extraction_html(
1831 self, fakepath, fakefilefactory, const1, const2
1833 sigconst = "HTML signature from {path} but as a string"
1834 sig = f'<div id="signature">{sigconst.format(path=fakepath)}</div>'
1836 sig_f = fakefilefactory(fakepath, content=sig)
1838 origtext, textsig, htmlsig = extract_signature(
1839 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER} {fakepath}\n{const2}",
1840 filefactory=fakefilefactory,
1842 assert origtext == const1
1843 assert textsig == const2
1844 assert htmlsig == sigconst.format(path=fakepath)
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}"
1853 @pytest.mark.imgproc
1854 def test_image_registry(self, const1):
1855 reg = ImageRegistry()
1856 cid = reg.register(const1)
1858 assert not cid.startswith("<")
1859 assert not cid.endswith(">")
1860 assert const1 in reg
1862 @pytest.mark.imgproc
1863 def test_image_registry_file_uri(self, const1):
1864 reg = ImageRegistry()
1865 reg.register("/some/path")
1867 assert path.startswith("file://")
1870 @pytest.mark.converter
1872 def test_converter_signature_handling(
1873 self, fakepath, fakefilefactory, monkeypatch
1876 "This is the mail body\n",
1878 "This is a plain-text signature only",
1883 fakepath, content="".join(mailparts)
1885 monkeypatch.context() as m,
1887 m.setattr(markdown.Markdown, "convert", lambda s, t: t)
1888 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1890 soup = bs4.BeautifulSoup(
1891 fakefilefactory[fakepath.with_suffix(".html")].read(),
1894 body = soup.body.contents
1896 assert mailparts[0] in body.pop(0)
1898 sig = soup.select_one("#signature")
1899 assert sig == body.pop(0)
1901 sep = sig.select_one("span.sig_separator")
1902 assert sep == sig.contents[0]
1903 assert f"\n{sep.text}\n" == EMAIL_SIG_SEP
1905 assert mailparts[2] in sig.contents[1]
1907 @pytest.mark.converter
1909 def test_converter_signature_handling_htmlsig(
1910 self, fakepath, fakepath2, fakefilefactory, monkeypatch
1913 "This is the mail body",
1915 f"{HTML_SIG_MARKER}{fakepath2}\n",
1916 "This is the plain-text version",
1918 htmlsig = "HTML Signature from {path} but as a string"
1920 f'<div id="signature"><p>{htmlsig.format(path=fakepath2)}</p></div>'
1923 sig_f = fakefilefactory(fakepath2, content=html)
1930 fakepath, content="".join(mailparts)
1932 monkeypatch.context() as m,
1935 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1937 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1939 soup = bs4.BeautifulSoup(
1940 fakefilefactory[fakepath.with_suffix(".html")].read(),
1943 sig = soup.select_one("#signature")
1946 assert HTML_SIG_MARKER not in sig.text
1947 assert htmlsig.format(path=fakepath2) == sig.text.strip()
1949 plaintext = fakefilefactory[fakepath].read()
1950 assert plaintext.endswith(EMAIL_SIG_SEP + mailparts[-1])
1952 @pytest.mark.converter
1954 def test_converter_signature_handling_htmlsig_with_image(
1955 self, fakepath, fakepath2, fakefilefactory, monkeypatch, test_png
1958 "This is the mail body",
1960 f"{HTML_SIG_MARKER}{fakepath2}\n",
1961 "This is the plain-text version",
1964 "HTML Signature from {path} with image\n"
1965 f'<img src="{test_png}">\n'
1968 f'<div id="signature">{htmlsig.format(path=fakepath2)}</div>'
1971 sig_f = fakefilefactory(fakepath2, content=html)
1978 fakepath, content="".join(mailparts)
1980 monkeypatch.context() as m,
1983 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1985 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1987 assert fakefilefactory.pop()[0].suffix == ".png"
1989 soup = bs4.BeautifulSoup(
1990 fakefilefactory[fakepath.with_suffix(".html")].read(),
1993 assert soup.img.attrs["src"].startswith("cid:")
1995 @pytest.mark.converter
1997 def test_converter_signature_handling_textsig_with_image(
1998 self, fakepath, fakefilefactory, test_png
2001 "This is the mail body",
2003 "This is the plain-text version with image\n",
2004 f"![Inline]({test_png})",
2008 fakepath, content="".join(mailparts)
2011 tree = convert_markdown_to_html(
2012 draft_f, filefactory=fakefilefactory
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()
2022 @pytest.mark.converter
2023 def test_converter_attribution_to_admonition(
2024 self, fakepath, fakefilefactory
2027 "Regarding whatever",
2028 "> blockquote line1",
2029 "> blockquote line2",
2031 "> new para with **bold** text",
2033 with fakefilefactory(
2034 fakepath, content="\n".join(mailparts)
2036 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2038 soup = bs4.BeautifulSoup(
2039 fakefilefactory[fakepath.with_suffix(".html")].read(),
2042 quote = soup.select_one("div.admonition.quote")
2045 soup.select_one("p.admonition-title").extract().text.strip()
2049 p = quote.p.extract()
2050 assert p.text.strip() == "\n".join(p[2:] for p in mailparts[1:3])
2052 p = quote.p.extract()
2053 assert p.contents[1].name == "strong"
2055 @pytest.mark.converter
2056 def test_converter_attribution_to_admonition_with_blockquote(
2057 self, fakepath, fakefilefactory
2060 "Regarding whatever",
2061 "> blockquote line1",
2062 "> blockquote line2",
2064 "> new para with **bold** text",
2066 with fakefilefactory(
2067 fakepath, content="\n".join(mailparts)
2069 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2071 soup = bs4.BeautifulSoup(
2072 fakefilefactory[fakepath.with_suffix(".html")].read(),
2075 quote = soup.select_one("div.admonition.quote")
2076 assert quote.blockquote
2078 @pytest.mark.converter
2079 def test_converter_attribution_to_admonition_multiple(
2080 self, fakepath, fakefilefactory
2083 "Regarding whatever",
2084 "> blockquote line1",
2085 "> blockquote line2",
2089 "> continued emailquote",
2091 "Another email-quote",
2094 with fakefilefactory(
2095 fakepath, content="\n".join(mailparts)
2097 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2099 soup = bs4.BeautifulSoup(
2100 fakefilefactory[fakepath.with_suffix(".html")].read(),
2103 quote = soup.select_one("div.admonition.quote.continued").extract()
2106 quote.select_one("p.admonition-title").extract().text.strip()
2110 p = quote.p.extract()
2113 quote = soup.select_one("div.admonition.quote.continued").extract()
2116 quote.select_one("p.admonition-title").extract().text.strip()
2121 def test_file_class_contextmanager(self, const1, monkeypatch):
2122 state = dict(o=False, c=False)
2127 with monkeypatch.context() as m:
2128 m.setattr(File, "open", lambda s: fn("o"))
2129 m.setattr(File, "close", lambda s: fn("c"))
2132 assert not state["c"]
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
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
2148 def test_file_class_path_no_exists(self, fakepath):
2149 with pytest.raises(FileNotFoundError):
2150 File(fakepath, mode="r").open()
2153 def test_file_class_cache(self, tmp_path, const1, const2):
2154 path = tmp_path / "file"
2155 file = File(path, mode="w+")
2157 f.write(const1, cache=True)
2158 with open(path, mode="w") as f:
2161 assert f.read(cache=True) == const1
2164 def test_file_class_cache_init(self, const1):
2165 file = File(path=None, mode="r", content=const1)
2167 assert f.read() == const1
2170 def test_file_class_content_or_path(self, fakepath, const1):
2171 with pytest.raises(RuntimeError):
2172 file = File(path=fakepath, content=const1)
2175 def test_file_class_content_needs_read(self, const1):
2176 with pytest.raises(RuntimeError):
2177 file = File(mode="w", content=const1)
2180 def test_file_class_write_persists_close(self, const1):
2185 assert f.read() == const1
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
2192 assert f.read() == const2
2195 def test_file_factory(self):
2196 fact = FileFactory()
2198 assert isinstance(f, File)
2199 assert len(fact) == 1
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]
2211 def test_fake_file_factory_path_persistence(
2212 self, fakepath, fakefilefactory
2214 f1 = fakefilefactory(fakepath)
2215 assert f1 == fakefilefactory(fakepath)