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 # https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
34 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
35 # Released under the GPL-2+ licence, just like Mutt itself.
47 import xml.etree.ElementTree as etree
50 from collections import namedtuple, OrderedDict
51 from markdown.extensions import Extension
52 from markdown.blockprocessors import BlockProcessor
53 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
54 from email.utils import make_msgid
55 from urllib import request
58 def parse_cli_args(*args, **kwargs):
59 parser = argparse.ArgumentParser(
61 "NeoMutt helper to turn text/markdown email parts "
62 "into full-fledged MIME trees"
66 "Copyright © 2023 martin f. krafft <madduck@madduck.net>.\n"
67 "Released under the MIT licence"
72 metavar="EXT[,EXT[,EXT]]",
75 help="Markdown extension to use (comma-separated list)",
84 help="CSS file to merge with the final HTML",
87 parser.set_defaults(css_file=None)
90 "--related-to-html-only",
92 help="Make related content be sibling to HTML parts only",
95 def positive_integer(value):
103 raise ValueError("Must be a positive integer")
106 "--max-number-other-attachments",
108 type=positive_integer,
110 help="Maximum number of other attachments to expect",
117 help="Only build, don't send the message",
124 help="Specify temporary directory to use for attachments",
130 help="Turn on debug logging of commands generated to stderr",
136 help="Turn on debugging to stderr of the MIME tree walk",
143 help="Write the generated HTML to the file",
146 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
147 massage_p = subp.add_parser(
148 "massage", help="Massaging phase (internal use)"
151 massage_p.add_argument(
152 "--write-commands-to",
158 help="Temporary file path to write commands to",
161 massage_p.add_argument(
165 help="If provided, the script is invoked as editor on the mail draft",
168 return parser.parse_args(*args, **kwargs)
171 # [ FILE I/O HANDLING ] #######################################################
180 def __init__(self, path=None, mode="r", content=None, **kwargs):
183 raise RuntimeError("Cannot specify path and content for File")
186 path if isinstance(path, pathlib.Path) else pathlib.Path(path)
191 if content and not re.search(r"[r+]", mode):
192 raise RuntimeError("Cannot specify content without read mode")
195 File.Op.R: [content] if content else [],
200 self._kwargs = kwargs
205 self._file = open(self._path, self._mode, **self._kwargs)
206 elif "b" in self._mode:
207 self._file = io.BytesIO()
209 self._file = io.StringIO()
215 def __exit__(self, exc_type, exc_val, exc_tb):
221 self._cache[File.Op.R] = self._cache[File.Op.W]
224 def _get_cache(self, op):
225 return (b"" if "b" in self._mode else "").join(self._cache[op])
227 def _add_to_cache(self, op, s):
228 self._cache[op].append(s)
230 def read(self, *, cache=True):
231 if cache and self._cache[File.Op.R]:
232 return self._get_cache(File.Op.R)
234 if self._lastop == File.Op.W:
237 except io.UnsupportedOperation:
240 self._lastop = File.Op.R
243 self._add_to_cache(File.Op.R, self._file.read())
244 return self._get_cache(File.Op.R)
246 return self._file.read()
248 def write(self, s, *, cache=True):
250 if self._lastop == File.Op.R:
253 except io.UnsupportedOperation:
257 self._add_to_cache(File.Op.W, s)
259 self._cache[File.Op.R] = self._cache[File.Op.W]
261 written = self._file.write(s)
263 self._lastop = File.Op.W
266 path = property(lambda s: s._path)
270 f'<File path={self._path or "(buffered)"} open={bool(self._file)} '
271 f"rcache={sum(len(c) for c in self._rcache) if self._rcache is not None else False} "
272 f"wcache={sum(len(c) for c in self._wcache) if self._wcache is not None else False}>"
280 def __call__(self, path=None, mode="r", content=None, **kwargs):
281 f = File(path, mode, content, **kwargs)
282 self._files.append(f)
286 return self._files.__len__()
288 def pop(self, idx=-1):
289 return self._files.pop(idx)
291 def __getitem__(self, idx):
292 return self._files.__getitem__(idx)
294 def __contains__(self, f):
295 return self._files.__contains__(f)
298 class FakeFileFactory(FileFactory):
301 self._paths2files = OrderedDict()
303 def __call__(self, path=None, mode="r", content=None, **kwargs):
304 if path in self._paths2files:
305 return self._paths2files[path]
307 f = super().__call__(None, mode, content, **kwargs)
308 self._paths2files[path] = f
312 class FakeFile(File):
315 # this is quality Python! We do this so that the fake file, which has
316 # no path, fake-pretends to have a path for testing purposes.
318 f.__class__ = FakeFile
321 def __getitem__(self, path):
322 return self._paths2files.__getitem__(path)
324 def get(self, path, default):
325 return self._paths2files.get(path, default)
327 def pop(self, last=True):
328 return self._paths2files.popitem(last)
332 f"<FakeFileFactory nfiles={len(self._files)} "
333 f"paths={len(self._paths2files)}>"
337 # [ IMAGE HANDLING ] ##########################################################
340 InlineImageInfo = namedtuple(
341 "InlineImageInfo", ["cid", "desc"], defaults=[None]
347 self._images = OrderedDict()
349 def register(self, path, description=None):
350 # path = str(pathlib.Path(path).expanduser())
351 path = os.path.expanduser(path)
352 if path.startswith("/"):
353 path = f"file://{path}"
354 cid = make_msgid()[1:-1]
355 self._images[path] = InlineImageInfo(cid, description)
359 return self._images.__iter__()
361 def __getitem__(self, idx):
362 return self._images.__getitem__(idx)
365 return self._images.__len__()
368 return self._images.items()
371 return f"<ImageRegistry(items={len(self._images)})>"
374 return self._images.__str__()
377 class InlineImageExtension(Extension):
378 class RelatedImageInlineProcessor(ImageInlineProcessor):
379 def __init__(self, re, md, registry):
380 super().__init__(re, md)
381 self._registry = registry
383 def handleMatch(self, m, data):
384 el, start, end = super().handleMatch(m, data)
385 if "src" in el.attrib:
386 src = el.attrib["src"]
387 if "://" not in src or src.startswith("file://"):
388 # We only inline local content
389 cid = self._registry.register(
391 el.attrib.get("title", el.attrib.get("alt")),
393 el.attrib["src"] = f"cid:{cid}"
394 return el, start, end
396 def __init__(self, registry):
398 self._image_registry = registry
400 INLINE_PATTERN_NAME = "image_link"
402 def extendMarkdown(self, md):
403 md.registerExtension(self)
404 inline_image_proc = self.RelatedImageInlineProcessor(
405 IMAGE_LINK_RE, md, self._image_registry
407 md.inlinePatterns.register(
408 inline_image_proc, InlineImageExtension.INLINE_PATTERN_NAME, 150
412 def markdown_with_inline_image_support(
418 extension_configs=None,
421 image_registry if image_registry is not None else ImageRegistry()
423 inline_image_handler = InlineImageExtension(registry=registry)
424 extensions = extensions or []
425 extensions.append(inline_image_handler)
426 mdwn = markdown.Markdown(
427 extensions=extensions, extension_configs=extension_configs
430 htmltext = mdwn.convert(text)
432 def replace_image_with_cid(matchobj):
433 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
435 return f"(cid:{registry[m].cid}"
436 return matchobj.group(0)
438 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
439 return text, htmltext, registry, mdwn
442 # [ CSS STYLING ] #############################################################
454 from pygments.formatters import get_formatter_by_name
456 _CODEHILITE_CLASS = "codehilite"
458 _PYGMENTS_CSS = get_formatter_by_name(
459 "html", style="default"
460 ).get_style_defs(f".{_CODEHILITE_CLASS}")
466 def apply_styling(html, css):
470 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
475 # [ QUOTE HANDLING ] ##########################################################
478 class QuoteToAdmonitionExtension(Extension):
479 class EmailQuoteBlockProcessor(BlockProcessor):
480 RE = re.compile(r"(?:^|\n)>\s*(.*)")
482 def __init__(self, parser):
483 super().__init__(parser)
486 def test(self, parent, blocks):
487 if markdown.util.nearing_recursion_limit():
490 lines = blocks.splitlines()
495 elif not self.RE.search(lines[0]):
498 return len(lines) > 0
500 elif not self.RE.search(lines[0]) and self.RE.search(lines[1]):
503 elif self._title and self.RE.search(lines[1]):
508 def run(self, parent, blocks):
509 quotelines = blocks.pop(0).splitlines()
511 cont = bool(self._title)
512 if not self.RE.search(quotelines[0]):
513 self._title = quotelines.pop(0)
515 admonition = etree.SubElement(parent, "div")
517 "class", f"admonition quote{' continued' if cont else ''}"
519 self.parser.parseChunk(admonition, self._title)
521 admonition[0].set("class", "admonition-title")
522 self.parser.parseChunk(
523 admonition, "\n".join(self.clean(line) for line in quotelines)
527 def clean(klass, line):
528 m = klass.RE.match(line)
529 return m.group(1) if m else line
531 def extendMarkdown(self, md):
532 md.registerExtension(self)
533 email_quote_proc = self.EmailQuoteBlockProcessor(md.parser)
534 md.parser.blockprocessors.register(email_quote_proc, "emailquote", 25)
537 # [ PARTS GENERATION ] ########################################################
543 ["type", "subtype", "path", "desc", "cid", "orig"],
544 defaults=[None, None, False],
548 ret = f"<{self.type}/{self.subtype}>"
550 ret = f"{ret} cid:{self.cid}"
552 ret = f"{ret} ORIGINAL"
557 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
560 return f"<multipart/{self.subtype}> children={len(self.children)}"
563 return hash(str(self.subtype) + "".join(str(self.children)))
566 def collect_inline_images(
567 image_registry, *, tempdir=None, filefactory=FileFactory()
570 for path, info in image_registry.items():
571 if path.startswith("cid:"):
574 data = request.urlopen(path)
576 mimetype = data.headers["Content-Type"]
577 ext = mimetypes.guess_extension(mimetype)
578 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
579 path = pathlib.Path(tempfilename[1])
581 with filefactory(path, "w+b") as out_f:
582 out_f.write(data.read())
584 # filewriter_fn(path, data.read(), "w+b")
587 f'Inline image: "{info.desc}"'
589 else f"Inline image {str(len(relparts)+1)}"
592 Part(*mimetype.split("/"), path, cid=info.cid, desc=desc)
598 EMAIL_SIG_SEP = "\n-- \n"
599 HTML_SIG_MARKER = "=htmlsig "
602 def make_html_doc(body, sig=None):
607 '<meta http-equiv="content-type" content="text/html; charset=UTF-8">\n' # noqa: E501
608 '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n' # noqa: E501
617 f'{ret}<div id="signature"><span class="sig_separator">{EMAIL_SIG_SEP.strip(nl)}</span>\n' # noqa: E501
622 return f"{ret}\n </body>\n</html>"
625 def make_text_mail(text, sig=None):
626 return EMAIL_SIG_SEP.join((text, sig)) if sig else text
629 def extract_signature(text, *, filefactory=FileFactory()):
630 parts = text.split(EMAIL_SIG_SEP, 1)
632 return text, None, None
634 lines = parts[1].splitlines()
635 if lines[0].startswith(HTML_SIG_MARKER):
636 path = pathlib.Path(re.split(r" +", lines.pop(0), maxsplit=1)[1])
637 textsig = "\n".join(lines)
639 with filefactory(path.expanduser()) as sig_f:
640 sig_input = sig_f.read()
642 soup = bs4.BeautifulSoup(sig_input, "html.parser")
644 style = str(soup.style.extract()) if soup.style else ""
645 for sig_selector in (
655 sig = soup.select_one(sig_selector)
660 return parts[0], textsig, style + sig_input
662 if sig.attrs.get("id") == "signature":
663 sig = "".join(str(c) for c in sig.children)
665 return parts[0], textsig, style + str(sig)
667 return parts[0], parts[1], None
670 def convert_markdown_to_html(
673 related_to_html_only=False,
676 filefactory=FileFactory(),
679 extension_configs=None,
681 # TODO extension_configs need to be handled differently
682 extension_configs = extension_configs or {}
683 extension_configs.setdefault("pymdownx.highlight", {})[
685 ] = _CODEHILITE_CLASS
687 extensions = extensions or []
688 extensions.append(QuoteToAdmonitionExtension())
690 draft = draft_f.read()
691 origtext, textsig, htmlsig = extract_signature(
692 draft, filefactory=filefactory
700 ) = markdown_with_inline_image_support(
701 origtext, extensions=extensions, extension_configs=extension_configs
706 # TODO: decide what to do if there is no plain-text version
707 raise NotImplementedError("HTML signature but no text alternative")
709 soup = bs4.BeautifulSoup(htmlsig, "html.parser")
710 for img in soup.find_all("img"):
711 uri = img.attrs["src"]
712 desc = img.attrs.get("title", img.attrs.get("alt"))
713 cid = image_registry.register(uri, desc)
714 img.attrs["src"] = f"cid:{cid}"
724 ) = markdown_with_inline_image_support(
726 extensions=extensions,
727 extension_configs=extension_configs,
728 image_registry=image_registry,
732 origtext = make_text_mail(origtext, textsig)
733 draft_f.write(origtext)
735 "text", "plain", draft_f.path, "Plain-text version", orig=True
738 htmltext = make_html_doc(htmltext, htmlsig)
739 htmltext = apply_styling(htmltext, css_f.read() if css_f else None)
742 htmlpath = draft_f.path.with_suffix(".html")
744 htmlpath = pathlib.Path(
745 tempfile.mkstemp(suffix=".html", dir=tempdir)[1]
748 htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
750 out_f.write(htmltext)
751 htmlpart = Part("text", "html", htmlpath, "HTML version")
754 htmldump_f.write(htmltext)
756 imgparts = collect_inline_images(
757 image_registry, tempdir=tempdir, filefactory=filefactory
760 if related_to_html_only:
761 # If there are inline image part, they will be contained within a
762 # multipart/related part along with the HTML part only
764 # replace htmlpart with a multipart/related container of the HTML
765 # parts and the images
766 htmlpart = Multipart(
767 "relative", [htmlpart] + imgparts, "Group of related content"
771 "alternative", [textpart, htmlpart], "Group of alternative content"
775 # If there are inline image part, they will be siblings to the
776 # multipart/alternative tree within a multipart/related part
778 "alternative", [textpart, htmlpart], "Group of alternative content"
782 "relative", [altpart] + imgparts, "Group of related content"
788 class MIMETreeDFWalker:
789 def __init__(self, *, visitor_fn=None, debug=False):
790 self._visitor_fn = visitor_fn or self._echovisit
793 def _echovisit(self, node, ancestry, debugprint):
794 debugprint(f"node={node} ancestry={ancestry}")
796 def walk(self, root, *, visitor_fn=None):
798 Recursive function to implement a depth-dirst walk of the MIME-tree
801 if isinstance(root, list):
803 root = Multipart("mixed", children=root)
811 visitor_fn=visitor_fn or self._visitor_fn,
814 def _walk(self, node, *, ancestry, descendents, visitor_fn):
815 # Let's start by enumerating the parts at the current level. At the
816 # root level, ancestry will be the empty list, and we expect a
817 # multipart/* container at this level. Later, e.g. within a
818 # mutlipart/alternative container, the subtree will just be the
819 # alternative parts, while the top of the ancestry will be the
820 # multipart/alternative container, which we will process after the
823 lead = f"{'│ '*len(ancestry)}"
824 if isinstance(node, Multipart):
826 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
829 # Depth-first, so push the current container onto the ancestry
830 # stack, then descend …
831 ancestry.append(node)
832 self.debugprint(lead + "│ " * 2)
833 for child in node.children:
837 descendents=descendents,
838 visitor_fn=visitor_fn,
840 assert ancestry.pop() == node
841 sibling_descendents = descendents
842 descendents.extend(node.children)
845 self.debugprint(f"{lead}├{node}")
846 sibling_descendents = descendents
848 if False and ancestry:
849 self.debugprint(lead[:-1] + " │")
853 node, ancestry, sibling_descendents, debugprint=self.debugprint
856 def debugprint(self, s, **kwargs):
858 print(s, file=sys.stderr, **kwargs)
861 # [ RUN MODES ] ###############################################################
866 Stupid class to interface writing out Mutt commands. This is quite a hack
867 to deal with the fact that Mutt runs "push" commands in reverse order, so
868 all of a sudden, things become very complicated when mixing with "real"
871 Hence we keep two sets of commands, and one set of pushes. Commands are
872 added to the first until a push is added, after which commands are added to
873 the second set of commands.
875 On flush(), the first set is printed, followed by the pushes in reverse,
876 and then the second set is printed. All 3 sets are then cleared.
879 def __init__(self, out_f=sys.stdout, *, debug=False):
880 self._cmd1, self._push, self._cmd2 = [], [], []
892 s = s.replace('"', r"\"")
895 self._push.insert(0, s)
899 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
901 self._cmd1, self._push, self._cmd2 = [], [], []
903 def debugprint(self, s, **kwargs):
905 print(s, file=sys.stderr, **kwargs)
913 debug_commands=False,
915 temppath = temppath or pathlib.Path(
916 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
918 cmds = MuttCommands(out_f, debug=debug_commands)
920 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
922 cmds.cmd('set my_editor="$editor"')
923 cmds.cmd('set my_edit_headers="$edit_headers"')
924 cmds.cmd(f'set editor="{editor}"')
925 cmds.cmd("unset edit_headers")
926 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
927 cmds.push("<first-entry><edit-file>")
938 converter=convert_markdown_to_html,
939 related_to_html_only=True,
941 max_other_attachments=20,
943 debug_commands=False,
946 # Here's the big picture: we're being invoked as the editor on the email
947 # draft, and whatever commands we write to the file given as cmdpath will
948 # be run by the second source command in the macro definition.
950 # Let's start by cleaning up what the setup did (see above), i.e. we
951 # restore the $editor and $edit_headers variables, and also unset the
952 # variable used to identify the command file we're currently writing
954 cmds = MuttCommands(cmd_f, debug=debug_commands)
956 extensions = extensions.split(",") if extensions else []
960 htmldump_f=htmldump_f,
961 related_to_html_only=related_to_html_only,
963 extensions=extensions,
966 mimetree = MIMETreeDFWalker(debug=debug_walk)
968 state = dict(pos=1, tags={}, parts=1)
970 def visitor_fn(item, ancestry, descendents, *, debugprint=None):
972 Visitor function called for every node (part) of the MIME tree,
973 depth-first, and responsible for telling NeoMutt how to assemble
976 KILL_LINE = r"\Ca\Ck"
978 if isinstance(item, Part):
979 # We've hit a leaf-node, i.e. an alternative or a related part
980 # with actual content.
984 # The original source already exists in the NeoMutt tree, but
985 # the underlying file may have been modified, so we need to
986 # update the encoding, but that's it:
987 cmds.push("<first-entry>")
988 cmds.push("<update-encoding>")
990 # We really just need to be able to assume that at this point,
991 # NeoMutt is at position 1, and that we've processed only this
992 # part so far. Nevermind about actual attachments, we can
993 # safely ignore those as they stay at the end.
994 assert state["pos"] == 1
995 assert state["parts"] == 1
997 # … whereas all other parts need to be added, and they're all
998 # considered to be temporary and inline:
999 cmds.push(f"<attach-file>{item.path}<enter>")
1000 cmds.push("<toggle-unlink><toggle-disposition>")
1002 # This added a part at the end of the list of parts, and that's
1003 # just how many parts we've seen so far, so it's position in
1004 # the NeoMutt compose list is the count of parts
1006 state["pos"] = state["parts"]
1008 # If the item (including the original) comes with additional
1009 # information, then we might just as well update the NeoMutt
1012 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
1014 # Now for the biggest hack in this script, which is to handle
1015 # attachments, such as PDFs, that aren't related or alternatives.
1016 # The problem is that when we add an inline image, it always gets
1017 # appended to the list, i.e. inserted *after* other attachments.
1018 # Since we don't know the number of attachments, we also cannot
1019 # infer the postition of the new attachment. Therefore, we bubble
1020 # it all the way to the top, only to then move it down again:
1021 if state["pos"] > 1: # skip for the first part
1022 for i in range(max_other_attachments):
1023 # could use any number here, but has to be larger than the
1024 # number of possible attachments. The performance
1025 # difference of using a high number is negligible.
1026 # Bubble up the new part
1027 cmds.push("<move-up>")
1029 # As we push the part to the right position in the list (i.e.
1030 # the last of the subset of attachments this script added), we
1031 # must handle the situation that subtrees are skipped by
1032 # NeoMutt. Hence, the actual number of positions to move down
1033 # is decremented by the number of descendents so far
1035 for i in range(1, state["pos"] - len(descendents)):
1036 cmds.push("<move-down>")
1038 elif isinstance(item, Multipart):
1039 # This node has children, but we already visited them (see
1040 # above). The tags dictionary of State should contain a list of
1041 # their positions in the NeoMutt compose window, so iterate those
1042 # and tag the parts there:
1043 n_tags = len(state["tags"][item])
1044 for tag in state["tags"][item]:
1045 cmds.push(f"<jump>{tag}<enter><tag-entry>")
1047 if item.subtype == "alternative":
1048 cmds.push("<group-alternatives>")
1049 elif item.subtype in ("relative", "related"):
1050 cmds.push("<group-related>")
1051 elif item.subtype == "multilingual":
1052 cmds.push("<group-multilingual>")
1054 raise NotImplementedError(
1055 f"Handling of multipart/{item.subtype} is not implemented"
1058 state["pos"] -= n_tags - 1
1062 # We should never get here
1063 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
1065 # If the item has a description, we might just as well add it
1067 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
1070 # If there's an ancestry, record the current (assumed) position in
1071 # the NeoMutt compose window as needed-to-tag by our direct parent
1072 # (i.e. the last item of the ancestry)
1073 state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
1075 lead = "│ " * (len(ancestry) + 1) + "* "
1077 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
1078 f"{lead}descendents={[d.subtype for d in descendents]}\n"
1079 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
1080 f"{lead}pos={state['pos']}, parts={state['parts']}"
1086 # Let's walk the tree and visit every node with our fancy visitor
1088 mimetree.walk(tree, visitor_fn=visitor_fn)
1091 cmds.push("<send-message>")
1093 # Finally, cleanup. Since we're responsible for removing the temporary
1094 # file, how's this for a little hack?
1096 filename = cmd_f.name
1097 except AttributeError:
1098 filename = "pytest_internal_file"
1099 cmds.cmd(f"source 'rm -f {filename}|'")
1100 cmds.cmd('set editor="$my_editor"')
1101 cmds.cmd('set edit_headers="$my_edit_headers"')
1102 cmds.cmd("unset my_editor")
1103 cmds.cmd("unset my_edit_headers")
1104 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
1108 # [ CLI ENTRY ] ###############################################################
1110 if __name__ == "__main__":
1111 args = parse_cli_args()
1113 if args.mode is None:
1115 tempdir=args.tempdir,
1116 debug_commands=args.debug_commands,
1119 elif args.mode == "massage":
1121 File(args.MAILDRAFT, "r+") as draft_f,
1122 File(args.cmdpath, "w") as cmd_f,
1123 File(args.css_file, "r") as css_f,
1124 File(args.dump_html, "w") as htmldump_f,
1129 extensions=args.extensions,
1131 htmldump_f=htmldump_f,
1132 related_to_html_only=args.related_to_html_only,
1133 max_other_attachments=args.max_number_other_attachments,
1134 only_build=args.only_build,
1135 tempdir=args.tempdir,
1136 debug_commands=args.debug_commands,
1137 debug_walk=args.debug_walk,
1141 # [ TESTS ] ###################################################################
1149 return "Curvature Vest Usher Dividing+T#iceps Senior"
1153 return "Habitant Celestial 2litzy Resurf/ce Headpiece Harmonics"
1157 return pathlib.Path("/does/not/exist")
1160 def fakepath2(self):
1161 return pathlib.Path("/does/not/exist/either")
1163 # NOTE: tests using the capsys fixture must specify sys.stdout to the
1164 # functions they call, else old stdout is used and not captured
1166 @pytest.mark.muttctrl
1167 def test_MuttCommands_cmd(self, const1, const2, capsys):
1168 "Assert order of commands"
1169 cmds = MuttCommands(out_f=sys.stdout)
1173 captured = capsys.readouterr()
1174 assert captured.out == "\n".join((const1, const2, ""))
1176 @pytest.mark.muttctrl
1177 def test_MuttCommands_push(self, const1, const2, capsys):
1178 "Assert reverse order of pushes"
1179 cmds = MuttCommands(out_f=sys.stdout)
1183 captured = capsys.readouterr()
1186 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
1189 @pytest.mark.muttctrl
1190 def test_MuttCommands_push_escape(self, const1, const2, capsys):
1191 cmds = MuttCommands(out_f=sys.stdout)
1192 cmds.push(f'"{const1}"')
1194 captured = capsys.readouterr()
1195 assert f'"\\"{const1}\\""' in captured.out
1197 @pytest.mark.muttctrl
1198 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
1199 "Assert reverse order of pushes"
1200 cmds = MuttCommands(out_f=sys.stdout)
1201 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
1203 cmds.cmd(lines[4 * i + 0])
1204 cmds.cmd(lines[4 * i + 1])
1205 cmds.push(lines[4 * i + 2])
1206 cmds.push(lines[4 * i + 3])
1209 captured = capsys.readouterr()
1210 lines_out = captured.out.splitlines()
1211 assert lines[0] in lines_out[0]
1212 assert lines[1] in lines_out[1]
1213 assert lines[7] in lines_out[2]
1214 assert lines[6] in lines_out[3]
1215 assert lines[3] in lines_out[4]
1216 assert lines[2] in lines_out[5]
1217 assert lines[4] in lines_out[6]
1218 assert lines[5] in lines_out[7]
1221 def mime_tree_related_to_alternative(self):
1235 Part("text", "html", "part.html", desc="HTML"),
1240 "text", "png", "logo.png", cid="logo.png", desc="Logo"
1247 def mime_tree_related_to_html(self):
1261 Part("text", "html", "part.html", desc="HTML"),
1277 def mime_tree_nested(self):
1298 desc="Nested plain",
1307 desc="Nested alternative",
1323 @pytest.mark.treewalk
1324 def test_MIMETreeDFWalker_depth_first_walk(
1325 self, mime_tree_related_to_alternative
1327 mimetree = MIMETreeDFWalker()
1331 def visitor_fn(item, ancestry, descendents, debugprint):
1332 items.append((item, len(ancestry), len(descendents)))
1335 mime_tree_related_to_alternative, visitor_fn=visitor_fn
1337 assert len(items) == 5
1338 assert items[0][0].subtype == "plain"
1339 assert items[0][1] == 2
1340 assert items[0][2] == 0
1341 assert items[1][0].subtype == "html"
1342 assert items[1][1] == 2
1343 assert items[1][2] == 0
1344 assert items[2][0].subtype == "alternative"
1345 assert items[2][1] == 1
1346 assert items[2][2] == 2
1347 assert items[3][0].subtype == "png"
1348 assert items[3][1] == 1
1349 assert items[3][2] == 2
1350 assert items[4][0].subtype == "relative"
1351 assert items[4][1] == 0
1352 assert items[4][2] == 4
1354 @pytest.mark.treewalk
1355 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
1356 mimetree = MIMETreeDFWalker()
1359 def visitor_fn(item, ancestry, descendents, debugprint):
1362 p = Part("text", "plain", const1)
1363 mimetree.walk([p], visitor_fn=visitor_fn)
1364 assert items[-1].subtype == "plain"
1365 mimetree.walk([p, p], visitor_fn=visitor_fn)
1366 assert items[-1].subtype == "mixed"
1368 @pytest.mark.treewalk
1369 def test_MIMETreeDFWalker_visitor_in_constructor(
1370 self, mime_tree_related_to_alternative
1374 def visitor_fn(item, ancestry, descendents, debugprint):
1377 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
1378 mimetree.walk(mime_tree_related_to_alternative)
1379 assert len(items) == 5
1382 def string_io(self, const1, text=None):
1383 return StringIO(text or const1)
1385 @pytest.mark.massage
1386 def test_do_massage_basic(self):
1387 def converter(draft_f, **kwargs):
1388 return Part("text", "plain", draft_f.path, orig=True)
1390 with File() as draft_f, File() as cmd_f:
1394 converter=converter,
1396 lines = cmd_f.read().splitlines()
1398 assert "send-message" in lines.pop(0)
1399 assert "update-encoding" in lines.pop(0)
1400 assert "first-entry" in lines.pop(0)
1401 assert "source 'rm -f " in lines.pop(0)
1402 assert '="$my_editor"' in lines.pop(0)
1403 assert '="$my_edit_headers"' in lines.pop(0)
1404 assert "unset my_editor" == lines.pop(0)
1405 assert "unset my_edit_headers" == lines.pop(0)
1406 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
1408 @pytest.mark.massage
1409 def test_do_massage_fulltree(self, mime_tree_related_to_alternative):
1410 def converter(draft_f, **kwargs):
1411 return mime_tree_related_to_alternative
1415 with File() as draft_f, File() as cmd_f:
1419 max_other_attachments=max_attachments,
1420 converter=converter,
1422 lines = cmd_f.read().splitlines()[:-6]
1424 assert "first-entry" in lines.pop()
1425 assert "update-encoding" in lines.pop()
1426 assert "Plain" in lines.pop()
1427 assert "part.html" in lines.pop()
1428 assert "toggle-unlink" in lines.pop()
1429 for i in range(max_attachments):
1430 assert "move-up" in lines.pop()
1431 assert "move-down" in lines.pop()
1432 assert "HTML" in lines.pop()
1433 assert "jump>1" in lines.pop()
1434 assert "jump>2" in lines.pop()
1435 assert "group-alternatives" in lines.pop()
1436 assert "Alternative" in lines.pop()
1437 assert "logo.png" in lines.pop()
1438 assert "toggle-unlink" in lines.pop()
1439 assert "content-id" in lines.pop()
1440 for i in range(max_attachments):
1441 assert "move-up" in lines.pop()
1442 assert "move-down" in lines.pop()
1443 assert "Logo" in lines.pop()
1444 assert "jump>1" in lines.pop()
1445 assert "jump>4" in lines.pop()
1446 assert "group-related" in lines.pop()
1447 assert "Related" in lines.pop()
1448 assert "send-message" in lines.pop()
1449 assert len(lines) == 0
1451 @pytest.mark.massage
1452 def test_mime_tree_relative_within_alternative(
1453 self, mime_tree_related_to_html
1455 def converter(draft_f, **kwargs):
1456 return mime_tree_related_to_html
1458 with File() as draft_f, File() as cmd_f:
1462 converter=converter,
1464 lines = cmd_f.read().splitlines()[:-6]
1466 assert "first-entry" in lines.pop()
1467 assert "update-encoding" in lines.pop()
1468 assert "Plain" in lines.pop()
1469 assert "part.html" in lines.pop()
1470 assert "toggle-unlink" in lines.pop()
1471 assert "move-up" in lines.pop()
1474 if "move-up" not in top:
1476 assert "move-down" in top
1477 assert "HTML" in lines.pop()
1478 assert "logo.png" in lines.pop()
1479 assert "toggle-unlink" in lines.pop()
1480 assert "content-id" in lines.pop()
1481 assert "move-up" in lines.pop()
1484 if "move-up" not in top:
1486 assert "move-down" in top
1487 assert "move-down" in lines.pop()
1488 assert "Logo" in lines.pop()
1489 assert "jump>2" in lines.pop()
1490 assert "jump>3" in lines.pop()
1491 assert "group-related" in lines.pop()
1492 assert "Related" in lines.pop()
1493 assert "jump>1" in lines.pop()
1494 assert "jump>2" in lines.pop()
1495 assert "group-alternative" in lines.pop()
1496 assert "Alternative" in lines.pop()
1497 assert "send-message" in lines.pop()
1498 assert len(lines) == 0
1500 @pytest.mark.massage
1501 def test_mime_tree_nested_trees_does_not_break_positioning(
1502 self, mime_tree_nested
1504 def converter(draft_f, **kwargs):
1505 return mime_tree_nested
1507 with File() as draft_f, File() as cmd_f:
1511 converter=converter,
1513 lines = cmd_f.read().splitlines()
1515 while "logo.png" not in lines.pop():
1518 assert "content-id" in lines.pop()
1519 assert "move-up" in lines.pop()
1522 if "move-up" not in top:
1524 assert "move-down" in top
1525 # Due to the nested trees, the number of descendents of the sibling
1526 # actually needs to be considered, not just the nieces. So to move
1527 # from position 1 to position 6, it only needs one <move-down>
1528 # because that jumps over the entire sibling tree. Thus what
1529 # follows next must not be another <move-down>
1530 assert "Logo" in lines.pop()
1532 @pytest.mark.converter
1533 def test_converter_tree_basic(self, fakepath, const1, fakefilefactory):
1534 with fakefilefactory(fakepath, content=const1) as draft_f:
1535 tree = convert_markdown_to_html(
1536 draft_f, filefactory=fakefilefactory
1539 assert tree.subtype == "alternative"
1540 assert len(tree.children) == 2
1541 assert tree.children[0].subtype == "plain"
1542 assert tree.children[0].path == draft_f.path
1543 assert tree.children[0].orig
1544 assert tree.children[1].subtype == "html"
1545 assert tree.children[1].path == fakepath.with_suffix(".html")
1547 @pytest.mark.converter
1548 def test_converter_writes(
1549 self, fakepath, fakefilefactory, const1, monkeypatch
1551 with fakefilefactory(fakepath, content=const1) as draft_f:
1552 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1554 html = fakefilefactory.pop()
1555 assert fakepath.with_suffix(".html") == html[0]
1556 assert const1 in html[1].read()
1557 text = fakefilefactory.pop()
1558 assert fakepath == text[0]
1559 assert const1 == text[1].read()
1561 @pytest.mark.imgproc
1562 def test_markdown_inline_image_processor(self):
1563 imgpath1 = "file:/path/to/image.png"
1564 imgpath2 = "file:///path/to/image.png?url=params"
1565 imgpath3 = "/path/to/image.png"
1566 text = f"""![inline local image]({imgpath1})
1568 with newline]({imgpath2})
1569 ![image local path]({imgpath3})"""
1570 text, html, images, mdwn = markdown_with_inline_image_support(text)
1572 # local paths have been normalised to URLs:
1573 imgpath3 = f"file://{imgpath3}"
1575 assert 'src="cid:' in html
1576 assert "](cid:" in text
1577 assert len(images) == 3
1578 assert imgpath1 in images
1579 assert imgpath2 in images
1580 assert imgpath3 in images
1581 assert images[imgpath1].cid != images[imgpath2].cid
1582 assert images[imgpath1].cid != images[imgpath3].cid
1583 assert images[imgpath2].cid != images[imgpath3].cid
1585 @pytest.mark.imgproc
1586 def test_markdown_inline_image_processor_title_to_desc(self, const1):
1587 imgpath = "file:///path/to/image.png"
1588 text = f'![inline local image]({imgpath} "{const1}")'
1589 text, html, images, mdwn = markdown_with_inline_image_support(text)
1590 assert images[imgpath].desc == const1
1592 @pytest.mark.imgproc
1593 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
1594 imgpath = "file:///path/to/image.png"
1595 text = f"![{const1}]({imgpath})"
1596 text, html, images, mdwn = markdown_with_inline_image_support(text)
1597 assert images[imgpath].desc == const1
1599 @pytest.mark.imgproc
1600 def test_markdown_inline_image_processor_title_over_alt_desc(
1601 self, const1, const2
1603 imgpath = "file:///path/to/image.png"
1604 text = f'![{const1}]({imgpath} "{const2}")'
1605 text, html, images, mdwn = markdown_with_inline_image_support(text)
1606 assert images[imgpath].desc == const2
1608 @pytest.mark.imgproc
1609 def test_markdown_inline_image_not_external(self):
1610 imgpath = "https://path/to/image.png"
1611 text = f"![inline image]({imgpath})"
1612 text, html, images, mdwn = markdown_with_inline_image_support(text)
1614 assert 'src="cid:' not in html
1615 assert "](cid:" not in text
1616 assert len(images) == 0
1618 @pytest.mark.imgproc
1619 def test_markdown_inline_image_local_file(self):
1620 imgpath = "/path/to/image.png"
1621 text = f"![inline image]({imgpath})"
1622 text, html, images, mdwn = markdown_with_inline_image_support(text)
1624 for k, v in images.items():
1625 assert k == f"file://{imgpath}"
1628 @pytest.mark.imgproc
1629 def test_markdown_inline_image_expanduser(self):
1630 imgpath = pathlib.Path("~/image.png")
1631 text = f"![inline image]({imgpath})"
1632 text, html, images, mdwn = markdown_with_inline_image_support(text)
1634 for k, v in images.items():
1635 assert k == f"file://{imgpath.expanduser()}"
1641 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1642 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
1645 @pytest.mark.imgproc
1646 def test_markdown_inline_image_processor_base64(self, test_png):
1647 text = f"![1px white inlined]({test_png})"
1648 text, html, images, mdwn = markdown_with_inline_image_support(text)
1650 assert 'src="cid:' in html
1651 assert "](cid:" in text
1652 assert len(images) == 1
1653 assert test_png in images
1655 @pytest.mark.converter
1656 def test_converter_tree_inline_image_base64(
1657 self, test_png, fakefilefactory
1659 text = f"![inline base64 image]({test_png})"
1660 with fakefilefactory(content=text) as draft_f:
1661 tree = convert_markdown_to_html(
1663 filefactory=fakefilefactory,
1664 related_to_html_only=False,
1666 assert tree.subtype == "relative"
1667 assert tree.children[0].subtype == "alternative"
1668 assert tree.children[1].subtype == "png"
1669 written = fakefilefactory.pop()
1670 assert tree.children[1].path == written[0]
1671 assert b"PNG" in written[1].read()
1673 @pytest.mark.converter
1674 def test_converter_tree_inline_image_base64_related_to_html(
1675 self, test_png, fakefilefactory
1677 text = f"![inline base64 image]({test_png})"
1678 with fakefilefactory(content=text) as draft_f:
1679 tree = convert_markdown_to_html(
1681 filefactory=fakefilefactory,
1682 related_to_html_only=True,
1684 assert tree.subtype == "alternative"
1685 assert tree.children[1].subtype == "relative"
1686 assert tree.children[1].children[1].subtype == "png"
1687 written = fakefilefactory.pop()
1688 assert tree.children[1].children[1].path == written[0]
1689 assert b"PNG" in written[1].read()
1691 @pytest.mark.converter
1692 def test_converter_tree_inline_image_cid(
1693 self, const1, fakefilefactory
1695 text = f"![inline base64 image](cid:{const1})"
1696 with fakefilefactory(content=text) as draft_f:
1697 tree = convert_markdown_to_html(
1699 filefactory=fakefilefactory,
1700 related_to_html_only=False,
1702 assert len(tree.children) == 2
1703 assert tree.children[0].cid != const1
1704 assert tree.children[0].type != "image"
1705 assert tree.children[1].cid != const1
1706 assert tree.children[1].type != "image"
1709 def fakefilefactory(self):
1710 return FakeFileFactory()
1712 @pytest.mark.imgcoll
1713 def test_inline_image_collection(
1714 self, test_png, const1, const2, fakefilefactory
1716 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1717 relparts = collect_inline_images(
1718 test_images, filefactory=fakefilefactory
1721 written = fakefilefactory.pop()
1722 assert b"PNG" in written[1].read()
1724 assert relparts[0].subtype == "png"
1725 assert relparts[0].path == written[0]
1726 assert relparts[0].cid == const1
1727 assert const2 in relparts[0].desc
1731 @pytest.mark.styling
1732 def test_apply_stylesheet(self):
1733 html = "<p>Hello, world!</p>"
1734 css = "p { color:red }"
1735 out = apply_styling(html, css)
1736 assert 'p style="color' in out
1738 @pytest.mark.styling
1739 def test_apply_no_stylesheet(self, const1):
1740 out = apply_styling(const1, None)
1742 @pytest.mark.massage
1743 @pytest.mark.styling
1744 def test_massage_styling_to_converter(self):
1745 css = "p { color:red }"
1748 def converter(draft_f, css_f, **kwargs):
1750 css_applied.append(css)
1751 return Part("text", "plain", draft_f.path, orig=True)
1755 File(mode="w") as cmd_f,
1756 File(content=css) as css_f
1762 converter=converter,
1764 assert css_applied[0] == css
1766 @pytest.mark.converter
1767 @pytest.mark.styling
1768 def test_converter_apply_styles(
1769 self, const1, monkeypatch, fakepath, fakefilefactory
1771 css = "p { color:red }"
1773 monkeypatch.context() as m,
1774 fakefilefactory(fakepath, content=const1) as draft_f,
1775 fakefilefactory(content=css) as css_f,
1780 lambda s, t: f"<p>{t}</p>",
1782 convert_markdown_to_html(
1783 draft_f, css_f=css_f, filefactory=fakefilefactory
1787 fakefilefactory[fakepath.with_suffix(".html")].read(),
1792 @pytest.mark.styling
1793 def test_apply_stylesheet_pygments(self):
1795 f'<div class="{_CODEHILITE_CLASS}">'
1796 "<pre>def foo():\n return</pre></div>"
1798 out = apply_styling(html, _PYGMENTS_CSS)
1799 assert f'{_CODEHILITE_CLASS}" style="' in out
1802 def test_signature_extraction_no_signature(self, const1):
1803 assert (const1, None, None) == extract_signature(const1)
1806 def test_signature_extraction_just_text(self, const1, const2):
1807 origtext, textsig, htmlsig = extract_signature(
1808 f"{const1}{EMAIL_SIG_SEP}{const2}"
1810 assert origtext == const1
1811 assert textsig == const2
1812 assert htmlsig is None
1815 def test_signature_extraction_html(
1816 self, fakepath, fakefilefactory, const1, const2
1818 sigconst = "HTML signature from {path} but as a string"
1819 sig = f'<div id="signature">{sigconst.format(path=fakepath)}</div>'
1821 sig_f = fakefilefactory(fakepath, content=sig)
1823 origtext, textsig, htmlsig = extract_signature(
1824 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER} {fakepath}\n{const2}",
1825 filefactory=fakefilefactory,
1827 assert origtext == const1
1828 assert textsig == const2
1829 assert htmlsig == sigconst.format(path=fakepath)
1832 def test_signature_extraction_file_not_found(self, fakepath, const1):
1833 with pytest.raises(FileNotFoundError):
1834 origtext, textsig, htmlsig = extract_signature(
1835 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER}{fakepath}\n{const1}"
1838 @pytest.mark.imgproc
1839 def test_image_registry(self, const1):
1840 reg = ImageRegistry()
1841 cid = reg.register(const1)
1843 assert not cid.startswith("<")
1844 assert not cid.endswith(">")
1845 assert const1 in reg
1847 @pytest.mark.imgproc
1848 def test_image_registry_file_uri(self, const1):
1849 reg = ImageRegistry()
1850 reg.register("/some/path")
1852 assert path.startswith("file://")
1855 @pytest.mark.converter
1857 def test_converter_signature_handling(
1858 self, fakepath, fakefilefactory, monkeypatch
1861 "This is the mail body\n",
1863 "This is a plain-text signature only",
1868 fakepath, content="".join(mailparts)
1870 monkeypatch.context() as m,
1872 m.setattr(markdown.Markdown, "convert", lambda s, t: t)
1873 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1875 soup = bs4.BeautifulSoup(
1876 fakefilefactory[fakepath.with_suffix(".html")].read(),
1879 body = soup.body.contents
1881 assert mailparts[0] in body.pop(0)
1883 sig = soup.select_one("#signature")
1884 assert sig == body.pop(0)
1886 sep = sig.select_one("span.sig_separator")
1887 assert sep == sig.contents[0]
1888 assert f"\n{sep.text}\n" == EMAIL_SIG_SEP
1890 assert mailparts[2] in sig.contents[1]
1892 @pytest.mark.converter
1894 def test_converter_signature_handling_htmlsig(
1895 self, fakepath, fakepath2, fakefilefactory, monkeypatch
1898 "This is the mail body",
1900 f"{HTML_SIG_MARKER}{fakepath2}\n",
1901 "This is the plain-text version",
1903 htmlsig = "HTML Signature from {path} but as a string"
1905 f'<div id="signature"><p>{htmlsig.format(path=fakepath2)}</p></div>'
1908 sig_f = fakefilefactory(fakepath2, content=html)
1915 fakepath, content="".join(mailparts)
1917 monkeypatch.context() as m,
1920 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1922 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1924 soup = bs4.BeautifulSoup(
1925 fakefilefactory[fakepath.with_suffix(".html")].read(),
1928 sig = soup.select_one("#signature")
1931 assert HTML_SIG_MARKER not in sig.text
1932 assert htmlsig.format(path=fakepath2) == sig.text.strip()
1934 plaintext = fakefilefactory[fakepath].read()
1935 assert plaintext.endswith(EMAIL_SIG_SEP + mailparts[-1])
1937 @pytest.mark.converter
1939 def test_converter_signature_handling_htmlsig_with_image(
1940 self, fakepath, fakepath2, fakefilefactory, monkeypatch, test_png
1943 "This is the mail body",
1945 f"{HTML_SIG_MARKER}{fakepath2}\n",
1946 "This is the plain-text version",
1949 "HTML Signature from {path} with image\n"
1950 f'<img src="{test_png}">\n'
1953 f'<div id="signature">{htmlsig.format(path=fakepath2)}</div>'
1956 sig_f = fakefilefactory(fakepath2, content=html)
1963 fakepath, content="".join(mailparts)
1965 monkeypatch.context() as m,
1968 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1970 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1972 assert fakefilefactory.pop()[0].suffix == ".png"
1974 soup = bs4.BeautifulSoup(
1975 fakefilefactory[fakepath.with_suffix(".html")].read(),
1978 assert soup.img.attrs["src"].startswith("cid:")
1980 @pytest.mark.converter
1982 def test_converter_signature_handling_textsig_with_image(
1983 self, fakepath, fakefilefactory, test_png
1986 "This is the mail body",
1988 "This is the plain-text version with image\n",
1989 f"![Inline]({test_png})",
1993 fakepath, content="".join(mailparts)
1996 tree = convert_markdown_to_html(
1997 draft_f, filefactory=fakefilefactory
2000 assert tree.subtype == "relative"
2001 assert tree.children[0].subtype == "alternative"
2002 assert tree.children[1].subtype == "png"
2003 written = fakefilefactory.pop()
2004 assert tree.children[1].path == written[0]
2005 assert written[1].read() == request.urlopen(test_png).read()
2007 @pytest.mark.converter
2008 def test_converter_attribution_to_admonition(
2009 self, fakepath, fakefilefactory
2012 "Regarding whatever",
2013 "> blockquote line1",
2014 "> blockquote line2",
2016 "> new para with **bold** text",
2018 with fakefilefactory(
2019 fakepath, content="\n".join(mailparts)
2021 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2023 soup = bs4.BeautifulSoup(
2024 fakefilefactory[fakepath.with_suffix(".html")].read(),
2027 quote = soup.select_one("div.admonition.quote")
2030 soup.select_one("p.admonition-title").extract().text.strip()
2034 p = quote.p.extract()
2035 assert p.text.strip() == "\n".join(p[2:] for p in mailparts[1:3])
2037 p = quote.p.extract()
2038 assert p.contents[1].name == "strong"
2040 @pytest.mark.converter
2041 def test_converter_attribution_to_admonition_multiple(
2042 self, fakepath, fakefilefactory
2045 "Regarding whatever",
2046 "> blockquote line1",
2047 "> blockquote line2",
2051 "> continued emailquote",
2053 "Another email-quote",
2056 with fakefilefactory(
2057 fakepath, content="\n".join(mailparts)
2059 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2061 soup = bs4.BeautifulSoup(
2062 fakefilefactory[fakepath.with_suffix(".html")].read(),
2065 quote = soup.select_one("div.admonition.quote.continued").extract()
2068 quote.select_one("p.admonition-title").extract().text.strip()
2072 p = quote.p.extract()
2075 quote = soup.select_one("div.admonition.quote.continued").extract()
2078 quote.select_one("p.admonition-title").extract().text.strip()
2083 def test_file_class_contextmanager(self, const1, monkeypatch):
2084 state = dict(o=False, c=False)
2089 with monkeypatch.context() as m:
2090 m.setattr(File, "open", lambda s: fn("o"))
2091 m.setattr(File, "close", lambda s: fn("c"))
2094 assert not state["c"]
2098 def test_file_class_no_path(self, const1):
2099 with File(mode="w+") as f:
2100 f.write(const1, cache=False)
2101 assert f.read(cache=False) == const1
2104 def test_file_class_path(self, const1, tmp_path):
2105 with File(tmp_path / "file", mode="w+") as f:
2106 f.write(const1, cache=False)
2107 assert f.read(cache=False) == const1
2110 def test_file_class_path_no_exists(self, fakepath):
2111 with pytest.raises(FileNotFoundError):
2112 File(fakepath, mode="r").open()
2115 def test_file_class_cache(self, tmp_path, const1, const2):
2116 path = tmp_path / "file"
2117 file = File(path, mode="w+")
2119 f.write(const1, cache=True)
2120 with open(path, mode="w") as f:
2123 assert f.read(cache=True) == const1
2126 def test_file_class_cache_init(self, const1):
2127 file = File(path=None, mode="r", content=const1)
2129 assert f.read() == const1
2132 def test_file_class_content_or_path(self, fakepath, const1):
2133 with pytest.raises(RuntimeError):
2134 file = File(path=fakepath, content=const1)
2137 def test_file_class_content_needs_read(self, const1):
2138 with pytest.raises(RuntimeError):
2139 file = File(mode="w", content=const1)
2142 def test_file_class_write_persists_close(self, const1):
2147 assert f.read() == const1
2150 def test_file_class_write_resets_read_cache(self, const1, const2):
2151 with File(mode="w+", content=const1) as f:
2152 assert f.read() == const1
2154 assert f.read() == const2
2157 def test_file_factory(self):
2158 fact = FileFactory()
2160 assert isinstance(f, File)
2161 assert len(fact) == 1
2166 def test_fake_file_factory(self, fakepath, fakefilefactory):
2167 fact = FakeFileFactory()
2168 f = fakefilefactory(fakepath)
2169 assert f.path == fakepath
2170 assert f == fakefilefactory[fakepath]
2173 def test_fake_file_factory_path_persistence(
2174 self, fakepath, fakefilefactory
2176 f1 = fakefilefactory(fakepath)
2177 assert f1 == fakefilefactory(fakepath)