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 contextlib import contextmanager
51 from collections import namedtuple, OrderedDict
52 from markdown.extensions import Extension
53 from markdown.blockprocessors import BlockProcessor
54 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
55 from email.utils import make_msgid
56 from urllib import request
59 def parse_cli_args(*args, **kwargs):
60 parser = argparse.ArgumentParser(
62 "NeoMutt helper to turn text/markdown email parts "
63 "into full-fledged MIME trees"
67 "Copyright © 2023 martin f. krafft <madduck@madduck.net>.\n"
68 "Released under the MIT licence"
73 metavar="EXT[,EXT[,EXT]]",
76 help="Markdown extension to use (comma-separated list)",
85 help="CSS file to merge with the final HTML",
88 parser.set_defaults(css_file=None)
91 "--related-to-html-only",
93 help="Make related content be sibling to HTML parts only",
96 def positive_integer(value):
104 raise ValueError("Must be a positive integer")
107 "--max-number-other-attachments",
109 type=positive_integer,
111 help="Maximum number of other attachments to expect",
118 help="Only build, don't send the message",
125 help="Specify temporary directory to use for attachments",
131 help="Turn on debug logging of commands generated to stderr",
137 help="Turn on debugging to stderr of the MIME tree walk",
144 help="Write the generated HTML to the file",
147 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
148 massage_p = subp.add_parser(
149 "massage", help="Massaging phase (internal use)"
152 massage_p.add_argument(
153 "--write-commands-to",
159 help="Temporary file path to write commands to",
162 massage_p.add_argument(
166 help="If provided, the script is invoked as editor on the mail draft",
169 return parser.parse_args(*args, **kwargs)
172 # [ FILE I/O HANDLING ] #######################################################
181 def __init__(self, path=None, mode="r", content=None, **kwargs):
184 raise RuntimeError("Cannot specify path and content for File")
187 path if isinstance(path, pathlib.Path) else pathlib.Path(path)
192 if content and not re.search(r"[r+]", mode):
193 raise RuntimeError("Cannot specify content without read mode")
196 File.Op.R: [content] if content else [],
201 self._kwargs = kwargs
206 self._file = open(self._path, self._mode, **self._kwargs)
207 elif "b" in self._mode:
208 self._file = io.BytesIO()
210 self._file = io.StringIO()
216 def __exit__(self, exc_type, exc_val, exc_tb):
222 self._cache[File.Op.R] = self._cache[File.Op.W]
225 def _get_cache(self, op):
226 return (b"" if "b" in self._mode else "").join(self._cache[op])
228 def _add_to_cache(self, op, s):
229 self._cache[op].append(s)
231 def read(self, *, cache=True):
232 if cache and self._cache[File.Op.R]:
233 return self._get_cache(File.Op.R)
235 if self._lastop == File.Op.W:
238 except io.UnsupportedOperation:
241 self._lastop = File.Op.R
244 self._add_to_cache(File.Op.R, self._file.read())
245 return self._get_cache(File.Op.R)
247 return self._file.read()
249 def write(self, s, *, cache=True):
251 if self._lastop == File.Op.R:
254 except io.UnsupportedOperation:
258 self._add_to_cache(File.Op.W, s)
260 self._cache[File.Op.R] = self._cache[File.Op.W]
262 written = self._file.write(s)
264 self._lastop = File.Op.W
267 path = property(lambda s: s._path)
271 f'<File path={self._path or "(buffered)"} open={bool(self._file)} '
272 f"rcache={sum(len(c) for c in self._rcache) if self._rcache is not None else False} "
273 f"wcache={sum(len(c) for c in self._wcache) if self._wcache is not None else False}>"
281 def __call__(self, path=None, mode="r", content=None, **kwargs):
282 f = File(path, mode, content, **kwargs)
283 self._files.append(f)
287 return self._files.__len__()
289 def pop(self, idx=-1):
290 return self._files.pop(idx)
292 def __getitem__(self, idx):
293 return self._files.__getitem__(idx)
295 def __contains__(self, f):
296 return self._files.__contains__(f)
299 class FakeFileFactory(FileFactory):
302 self._paths2files = OrderedDict()
304 def __call__(self, path=None, mode="r", content=None, **kwargs):
305 if path in self._paths2files:
306 return self._paths2files[path]
308 f = super().__call__(None, mode, content, **kwargs)
309 self._paths2files[path] = f
313 class FakeFile(File):
316 # this is quality Python! We do this so that the fake file, which has
317 # no path, fake-pretends to have a path for testing purposes.
319 f.__class__ = FakeFile
322 def __getitem__(self, path):
323 return self._paths2files.__getitem__(path)
325 def get(self, path, default):
326 return self._paths2files.get(path, default)
328 def pop(self, last=True):
329 return self._paths2files.popitem(last)
333 f"<FakeFileFactory nfiles={len(self._files)} "
334 f"paths={len(self._paths2files)}>"
338 # [ IMAGE HANDLING ] ##########################################################
341 InlineImageInfo = namedtuple(
342 "InlineImageInfo", ["cid", "desc"], defaults=[None]
348 self._images = OrderedDict()
350 def register(self, path, description=None):
351 # path = str(pathlib.Path(path).expanduser())
352 path = os.path.expanduser(path)
353 if path.startswith("/"):
354 path = f"file://{path}"
355 cid = make_msgid()[1:-1]
356 self._images[path] = InlineImageInfo(cid, description)
360 return self._images.__iter__()
362 def __getitem__(self, idx):
363 return self._images.__getitem__(idx)
366 return self._images.__len__()
369 return self._images.items()
372 return f"<ImageRegistry(items={len(self._images)})>"
375 return self._images.__str__()
378 class InlineImageExtension(Extension):
379 class RelatedImageInlineProcessor(ImageInlineProcessor):
380 def __init__(self, re, md, registry):
381 super().__init__(re, md)
382 self._registry = registry
384 def handleMatch(self, m, data):
385 el, start, end = super().handleMatch(m, data)
386 if "src" in el.attrib:
387 src = el.attrib["src"]
388 if "://" not in src or src.startswith("file://"):
389 # We only inline local content
390 cid = self._registry.register(
392 el.attrib.get("title", el.attrib.get("alt")),
394 el.attrib["src"] = f"cid:{cid}"
395 return el, start, end
397 def __init__(self, registry):
399 self._image_registry = registry
401 INLINE_PATTERN_NAME = "image_link"
403 def extendMarkdown(self, md):
404 md.registerExtension(self)
405 inline_image_proc = self.RelatedImageInlineProcessor(
406 IMAGE_LINK_RE, md, self._image_registry
408 md.inlinePatterns.register(
409 inline_image_proc, InlineImageExtension.INLINE_PATTERN_NAME, 150
413 def markdown_with_inline_image_support(
419 extension_configs=None,
422 image_registry if image_registry is not None else ImageRegistry()
424 inline_image_handler = InlineImageExtension(registry=registry)
425 extensions = extensions or []
426 extensions.append(inline_image_handler)
427 mdwn = markdown.Markdown(
428 extensions=extensions, extension_configs=extension_configs
431 htmltext = mdwn.convert(text)
433 def replace_image_with_cid(matchobj):
434 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
436 return f"(cid:{registry[m].cid}"
437 return matchobj.group(0)
439 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
440 return text, htmltext, registry, mdwn
443 # [ CSS STYLING ] #############################################################
455 from pygments.formatters import get_formatter_by_name
457 _CODEHILITE_CLASS = "codehilite"
459 _PYGMENTS_CSS = get_formatter_by_name(
460 "html", style="default"
461 ).get_style_defs(f".{_CODEHILITE_CLASS}")
467 def apply_styling(html, css):
471 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
476 # [ QUOTE HANDLING ] ##########################################################
479 class QuoteToAdmonitionExtension(Extension):
480 class EmailQuoteBlockProcessor(BlockProcessor):
481 RE = re.compile(r"(?:^|\n)>\s*(.*)")
483 def __init__(self, parser):
484 super().__init__(parser)
486 self._disable = False
488 def test(self, parent, blocks):
492 if markdown.util.nearing_recursion_limit():
495 lines = blocks.splitlines()
500 elif not self.RE.search(lines[0]):
503 return len(lines) > 0
505 elif not self.RE.search(lines[0]) and self.RE.search(lines[1]):
508 elif self._title and self.RE.search(lines[1]):
513 def run(self, parent, blocks):
514 quotelines = blocks.pop(0).splitlines()
516 cont = bool(self._title)
517 if not self.RE.search(quotelines[0]):
518 self._title = quotelines.pop(0)
520 admonition = etree.SubElement(parent, "div")
522 "class", f"admonition quote{' continued' if cont else ''}"
524 self.parser.parseChunk(admonition, self._title)
526 admonition[0].set("class", "admonition-title")
528 self.parser.parseChunk(
529 admonition, "\n".join(quotelines)
536 self._disable = False
539 def clean(klass, line):
540 m = klass.RE.match(line)
541 return m.group(1) if m else line
543 def extendMarkdown(self, md):
544 md.registerExtension(self)
545 email_quote_proc = self.EmailQuoteBlockProcessor(md.parser)
546 md.parser.blockprocessors.register(email_quote_proc, "emailquote", 25)
549 # [ PARTS GENERATION ] ########################################################
555 ["type", "subtype", "path", "desc", "cid", "orig"],
556 defaults=[None, None, False],
560 ret = f"<{self.type}/{self.subtype}>"
562 ret = f"{ret} cid:{self.cid}"
564 ret = f"{ret} ORIGINAL"
569 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
572 return f"<multipart/{self.subtype}> children={len(self.children)}"
575 return hash(str(self.subtype) + "".join(str(self.children)))
578 def collect_inline_images(
579 image_registry, *, tempdir=None, filefactory=FileFactory()
582 for path, info in image_registry.items():
583 if path.startswith("cid:"):
586 data = request.urlopen(path)
588 mimetype = data.headers["Content-Type"]
589 ext = mimetypes.guess_extension(mimetype)
590 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
591 path = pathlib.Path(tempfilename[1])
593 with filefactory(path, "w+b") as out_f:
594 out_f.write(data.read())
596 # filewriter_fn(path, data.read(), "w+b")
599 f'Inline image: "{info.desc}"'
601 else f"Inline image {str(len(relparts)+1)}"
604 Part(*mimetype.split("/"), path, cid=info.cid, desc=desc)
610 EMAIL_SIG_SEP = "\n-- \n"
611 HTML_SIG_MARKER = "=htmlsig "
614 def make_html_doc(body, sig=None):
619 '<meta http-equiv="content-type" content="text/html; charset=UTF-8">\n' # noqa: E501
620 '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n' # noqa: E501
629 f'{ret}<div id="signature"><span class="sig_separator">{EMAIL_SIG_SEP.strip(nl)}</span>\n' # noqa: E501
634 return f"{ret}\n </body>\n</html>"
637 def make_text_mail(text, sig=None):
638 return EMAIL_SIG_SEP.join((text, sig)) if sig else text
641 def extract_signature(text, *, filefactory=FileFactory()):
642 parts = text.split(EMAIL_SIG_SEP, 1)
644 return text, None, None
646 lines = parts[1].splitlines()
647 if lines[0].startswith(HTML_SIG_MARKER):
648 path = pathlib.Path(re.split(r" +", lines.pop(0), maxsplit=1)[1])
649 textsig = "\n".join(lines)
651 with filefactory(path.expanduser()) as sig_f:
652 sig_input = sig_f.read()
654 soup = bs4.BeautifulSoup(sig_input, "html.parser")
656 style = str(soup.style.extract()) if soup.style else ""
657 for sig_selector in (
667 sig = soup.select_one(sig_selector)
672 return parts[0], textsig, style + sig_input
674 if sig.attrs.get("id") == "signature":
675 sig = "".join(str(c) for c in sig.children)
677 return parts[0], textsig, style + str(sig)
679 return parts[0], parts[1], None
682 def convert_markdown_to_html(
685 related_to_html_only=False,
688 filefactory=FileFactory(),
691 extension_configs=None,
693 # TODO extension_configs need to be handled differently
694 extension_configs = extension_configs or {}
695 extension_configs.setdefault("pymdownx.highlight", {})[
697 ] = _CODEHILITE_CLASS
699 extensions = extensions or []
700 extensions.append(QuoteToAdmonitionExtension())
702 draft = draft_f.read()
703 origtext, textsig, htmlsig = extract_signature(
704 draft, filefactory=filefactory
712 ) = markdown_with_inline_image_support(
713 origtext, extensions=extensions, extension_configs=extension_configs
718 # TODO: decide what to do if there is no plain-text version
719 raise NotImplementedError("HTML signature but no text alternative")
721 soup = bs4.BeautifulSoup(htmlsig, "html.parser")
722 for img in soup.find_all("img"):
723 uri = img.attrs["src"]
724 desc = img.attrs.get("title", img.attrs.get("alt"))
725 cid = image_registry.register(uri, desc)
726 img.attrs["src"] = f"cid:{cid}"
736 ) = markdown_with_inline_image_support(
738 extensions=extensions,
739 extension_configs=extension_configs,
740 image_registry=image_registry,
744 origtext = make_text_mail(origtext, textsig)
745 draft_f.write(origtext)
747 "text", "plain", draft_f.path, "Plain-text version", orig=True
750 htmltext = make_html_doc(htmltext, htmlsig)
751 htmltext = apply_styling(htmltext, css_f.read() if css_f else None)
754 htmlpath = draft_f.path.with_suffix(".html")
756 htmlpath = pathlib.Path(
757 tempfile.mkstemp(suffix=".html", dir=tempdir)[1]
760 htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
762 out_f.write(htmltext)
763 htmlpart = Part("text", "html", htmlpath, "HTML version")
766 htmldump_f.write(htmltext)
768 imgparts = collect_inline_images(
769 image_registry, tempdir=tempdir, filefactory=filefactory
772 if related_to_html_only:
773 # If there are inline image part, they will be contained within a
774 # multipart/related part along with the HTML part only
776 # replace htmlpart with a multipart/related container of the HTML
777 # parts and the images
778 htmlpart = Multipart(
779 "relative", [htmlpart] + imgparts, "Group of related content"
783 "alternative", [textpart, htmlpart], "Group of alternative content"
787 # If there are inline image part, they will be siblings to the
788 # multipart/alternative tree within a multipart/related part
790 "alternative", [textpart, htmlpart], "Group of alternative content"
794 "relative", [altpart] + imgparts, "Group of related content"
800 class MIMETreeDFWalker:
801 def __init__(self, *, visitor_fn=None, debug=False):
802 self._visitor_fn = visitor_fn or self._echovisit
805 def _echovisit(self, node, ancestry, debugprint):
806 debugprint(f"node={node} ancestry={ancestry}")
808 def walk(self, root, *, visitor_fn=None):
810 Recursive function to implement a depth-dirst walk of the MIME-tree
813 if isinstance(root, list):
815 root = Multipart("mixed", children=root)
823 visitor_fn=visitor_fn or self._visitor_fn,
826 def _walk(self, node, *, ancestry, descendents, visitor_fn):
827 # Let's start by enumerating the parts at the current level. At the
828 # root level, ancestry will be the empty list, and we expect a
829 # multipart/* container at this level. Later, e.g. within a
830 # mutlipart/alternative container, the subtree will just be the
831 # alternative parts, while the top of the ancestry will be the
832 # multipart/alternative container, which we will process after the
835 lead = f"{'│ '*len(ancestry)}"
836 if isinstance(node, Multipart):
838 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
841 # Depth-first, so push the current container onto the ancestry
842 # stack, then descend …
843 ancestry.append(node)
844 self.debugprint(lead + "│ " * 2)
845 for child in node.children:
849 descendents=descendents,
850 visitor_fn=visitor_fn,
852 assert ancestry.pop() == node
853 sibling_descendents = descendents
854 descendents.extend(node.children)
857 self.debugprint(f"{lead}├{node}")
858 sibling_descendents = descendents
860 if False and ancestry:
861 self.debugprint(lead[:-1] + " │")
865 node, ancestry, sibling_descendents, debugprint=self.debugprint
868 def debugprint(self, s, **kwargs):
870 print(s, file=sys.stderr, **kwargs)
873 # [ RUN MODES ] ###############################################################
878 Stupid class to interface writing out Mutt commands. This is quite a hack
879 to deal with the fact that Mutt runs "push" commands in reverse order, so
880 all of a sudden, things become very complicated when mixing with "real"
883 Hence we keep two sets of commands, and one set of pushes. Commands are
884 added to the first until a push is added, after which commands are added to
885 the second set of commands.
887 On flush(), the first set is printed, followed by the pushes in reverse,
888 and then the second set is printed. All 3 sets are then cleared.
891 def __init__(self, out_f=sys.stdout, *, debug=False):
892 self._cmd1, self._push, self._cmd2 = [], [], []
904 s = s.replace('"', r"\"")
907 self._push.insert(0, s)
911 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
913 self._cmd1, self._push, self._cmd2 = [], [], []
915 def debugprint(self, s, **kwargs):
917 print(s, file=sys.stderr, **kwargs)
925 debug_commands=False,
927 temppath = temppath or pathlib.Path(
928 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
930 cmds = MuttCommands(out_f, debug=debug_commands)
932 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
934 cmds.cmd('set my_editor="$editor"')
935 cmds.cmd('set my_edit_headers="$edit_headers"')
936 cmds.cmd(f'set editor="{editor}"')
937 cmds.cmd("unset edit_headers")
938 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
939 cmds.push("<first-entry><edit-file>")
950 converter=convert_markdown_to_html,
951 related_to_html_only=True,
953 max_other_attachments=20,
955 debug_commands=False,
958 # Here's the big picture: we're being invoked as the editor on the email
959 # draft, and whatever commands we write to the file given as cmdpath will
960 # be run by the second source command in the macro definition.
962 # Let's start by cleaning up what the setup did (see above), i.e. we
963 # restore the $editor and $edit_headers variables, and also unset the
964 # variable used to identify the command file we're currently writing
966 cmds = MuttCommands(cmd_f, debug=debug_commands)
968 extensions = extensions.split(",") if extensions else []
972 htmldump_f=htmldump_f,
973 related_to_html_only=related_to_html_only,
975 extensions=extensions,
978 mimetree = MIMETreeDFWalker(debug=debug_walk)
980 state = dict(pos=1, tags={}, parts=1)
982 def visitor_fn(item, ancestry, descendents, *, debugprint=None):
984 Visitor function called for every node (part) of the MIME tree,
985 depth-first, and responsible for telling NeoMutt how to assemble
988 KILL_LINE = r"\Ca\Ck"
990 if isinstance(item, Part):
991 # We've hit a leaf-node, i.e. an alternative or a related part
992 # with actual content.
996 # The original source already exists in the NeoMutt tree, but
997 # the underlying file may have been modified, so we need to
998 # update the encoding, but that's it:
999 cmds.push("<first-entry>")
1000 cmds.push("<update-encoding>")
1002 # We really just need to be able to assume that at this point,
1003 # NeoMutt is at position 1, and that we've processed only this
1004 # part so far. Nevermind about actual attachments, we can
1005 # safely ignore those as they stay at the end.
1006 assert state["pos"] == 1
1007 assert state["parts"] == 1
1009 # … whereas all other parts need to be added, and they're all
1010 # considered to be temporary and inline:
1011 cmds.push(f"<attach-file>{item.path}<enter>")
1012 cmds.push("<toggle-unlink><toggle-disposition>")
1014 # This added a part at the end of the list of parts, and that's
1015 # just how many parts we've seen so far, so it's position in
1016 # the NeoMutt compose list is the count of parts
1018 state["pos"] = state["parts"]
1020 # If the item (including the original) comes with additional
1021 # information, then we might just as well update the NeoMutt
1024 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
1026 # Now for the biggest hack in this script, which is to handle
1027 # attachments, such as PDFs, that aren't related or alternatives.
1028 # The problem is that when we add an inline image, it always gets
1029 # appended to the list, i.e. inserted *after* other attachments.
1030 # Since we don't know the number of attachments, we also cannot
1031 # infer the postition of the new attachment. Therefore, we bubble
1032 # it all the way to the top, only to then move it down again:
1033 if state["pos"] > 1: # skip for the first part
1034 for i in range(max_other_attachments):
1035 # could use any number here, but has to be larger than the
1036 # number of possible attachments. The performance
1037 # difference of using a high number is negligible.
1038 # Bubble up the new part
1039 cmds.push("<move-up>")
1041 # As we push the part to the right position in the list (i.e.
1042 # the last of the subset of attachments this script added), we
1043 # must handle the situation that subtrees are skipped by
1044 # NeoMutt. Hence, the actual number of positions to move down
1045 # is decremented by the number of descendents so far
1047 for i in range(1, state["pos"] - len(descendents)):
1048 cmds.push("<move-down>")
1050 elif isinstance(item, Multipart):
1051 # This node has children, but we already visited them (see
1052 # above). The tags dictionary of State should contain a list of
1053 # their positions in the NeoMutt compose window, so iterate those
1054 # and tag the parts there:
1055 n_tags = len(state["tags"][item])
1056 for tag in state["tags"][item]:
1057 cmds.push(f"<jump>{tag}<enter><tag-entry>")
1059 if item.subtype == "alternative":
1060 cmds.push("<group-alternatives>")
1061 elif item.subtype in ("relative", "related"):
1062 cmds.push("<group-related>")
1063 elif item.subtype == "multilingual":
1064 cmds.push("<group-multilingual>")
1066 raise NotImplementedError(
1067 f"Handling of multipart/{item.subtype} is not implemented"
1070 state["pos"] -= n_tags - 1
1074 # We should never get here
1075 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
1077 # If the item has a description, we might just as well add it
1079 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
1082 # If there's an ancestry, record the current (assumed) position in
1083 # the NeoMutt compose window as needed-to-tag by our direct parent
1084 # (i.e. the last item of the ancestry)
1085 state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
1087 lead = "│ " * (len(ancestry) + 1) + "* "
1089 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
1090 f"{lead}descendents={[d.subtype for d in descendents]}\n"
1091 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
1092 f"{lead}pos={state['pos']}, parts={state['parts']}"
1098 # Let's walk the tree and visit every node with our fancy visitor
1100 mimetree.walk(tree, visitor_fn=visitor_fn)
1103 cmds.push("<send-message>")
1105 # Finally, cleanup. Since we're responsible for removing the temporary
1106 # file, how's this for a little hack?
1108 filename = cmd_f.name
1109 except AttributeError:
1110 filename = "pytest_internal_file"
1111 cmds.cmd(f"source 'rm -f {filename}|'")
1112 cmds.cmd('set editor="$my_editor"')
1113 cmds.cmd('set edit_headers="$my_edit_headers"')
1114 cmds.cmd("unset my_editor")
1115 cmds.cmd("unset my_edit_headers")
1116 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
1120 # [ CLI ENTRY ] ###############################################################
1122 if __name__ == "__main__":
1123 args = parse_cli_args()
1125 if args.mode is None:
1127 tempdir=args.tempdir,
1128 debug_commands=args.debug_commands,
1131 elif args.mode == "massage":
1133 File(args.MAILDRAFT, "r+") as draft_f,
1134 File(args.cmdpath, "w") as cmd_f,
1135 File(args.css_file, "r") as css_f,
1136 File(args.dump_html, "w") as htmldump_f,
1141 extensions=args.extensions,
1143 htmldump_f=htmldump_f,
1144 related_to_html_only=args.related_to_html_only,
1145 max_other_attachments=args.max_number_other_attachments,
1146 only_build=args.only_build,
1147 tempdir=args.tempdir,
1148 debug_commands=args.debug_commands,
1149 debug_walk=args.debug_walk,
1153 # [ TESTS ] ###################################################################
1161 return "Curvature Vest Usher Dividing+T#iceps Senior"
1165 return "Habitant Celestial 2litzy Resurf/ce Headpiece Harmonics"
1169 return pathlib.Path("/does/not/exist")
1172 def fakepath2(self):
1173 return pathlib.Path("/does/not/exist/either")
1175 # NOTE: tests using the capsys fixture must specify sys.stdout to the
1176 # functions they call, else old stdout is used and not captured
1178 @pytest.mark.muttctrl
1179 def test_MuttCommands_cmd(self, const1, const2, capsys):
1180 "Assert order of commands"
1181 cmds = MuttCommands(out_f=sys.stdout)
1185 captured = capsys.readouterr()
1186 assert captured.out == "\n".join((const1, const2, ""))
1188 @pytest.mark.muttctrl
1189 def test_MuttCommands_push(self, const1, const2, capsys):
1190 "Assert reverse order of pushes"
1191 cmds = MuttCommands(out_f=sys.stdout)
1195 captured = capsys.readouterr()
1198 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
1201 @pytest.mark.muttctrl
1202 def test_MuttCommands_push_escape(self, const1, const2, capsys):
1203 cmds = MuttCommands(out_f=sys.stdout)
1204 cmds.push(f'"{const1}"')
1206 captured = capsys.readouterr()
1207 assert f'"\\"{const1}\\""' in captured.out
1209 @pytest.mark.muttctrl
1210 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
1211 "Assert reverse order of pushes"
1212 cmds = MuttCommands(out_f=sys.stdout)
1213 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
1215 cmds.cmd(lines[4 * i + 0])
1216 cmds.cmd(lines[4 * i + 1])
1217 cmds.push(lines[4 * i + 2])
1218 cmds.push(lines[4 * i + 3])
1221 captured = capsys.readouterr()
1222 lines_out = captured.out.splitlines()
1223 assert lines[0] in lines_out[0]
1224 assert lines[1] in lines_out[1]
1225 assert lines[7] in lines_out[2]
1226 assert lines[6] in lines_out[3]
1227 assert lines[3] in lines_out[4]
1228 assert lines[2] in lines_out[5]
1229 assert lines[4] in lines_out[6]
1230 assert lines[5] in lines_out[7]
1233 def mime_tree_related_to_alternative(self):
1247 Part("text", "html", "part.html", desc="HTML"),
1252 "text", "png", "logo.png", cid="logo.png", desc="Logo"
1259 def mime_tree_related_to_html(self):
1273 Part("text", "html", "part.html", desc="HTML"),
1289 def mime_tree_nested(self):
1310 desc="Nested plain",
1319 desc="Nested alternative",
1335 @pytest.mark.treewalk
1336 def test_MIMETreeDFWalker_depth_first_walk(
1337 self, mime_tree_related_to_alternative
1339 mimetree = MIMETreeDFWalker()
1343 def visitor_fn(item, ancestry, descendents, debugprint):
1344 items.append((item, len(ancestry), len(descendents)))
1347 mime_tree_related_to_alternative, visitor_fn=visitor_fn
1349 assert len(items) == 5
1350 assert items[0][0].subtype == "plain"
1351 assert items[0][1] == 2
1352 assert items[0][2] == 0
1353 assert items[1][0].subtype == "html"
1354 assert items[1][1] == 2
1355 assert items[1][2] == 0
1356 assert items[2][0].subtype == "alternative"
1357 assert items[2][1] == 1
1358 assert items[2][2] == 2
1359 assert items[3][0].subtype == "png"
1360 assert items[3][1] == 1
1361 assert items[3][2] == 2
1362 assert items[4][0].subtype == "relative"
1363 assert items[4][1] == 0
1364 assert items[4][2] == 4
1366 @pytest.mark.treewalk
1367 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
1368 mimetree = MIMETreeDFWalker()
1371 def visitor_fn(item, ancestry, descendents, debugprint):
1374 p = Part("text", "plain", const1)
1375 mimetree.walk([p], visitor_fn=visitor_fn)
1376 assert items[-1].subtype == "plain"
1377 mimetree.walk([p, p], visitor_fn=visitor_fn)
1378 assert items[-1].subtype == "mixed"
1380 @pytest.mark.treewalk
1381 def test_MIMETreeDFWalker_visitor_in_constructor(
1382 self, mime_tree_related_to_alternative
1386 def visitor_fn(item, ancestry, descendents, debugprint):
1389 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
1390 mimetree.walk(mime_tree_related_to_alternative)
1391 assert len(items) == 5
1394 def string_io(self, const1, text=None):
1395 return StringIO(text or const1)
1397 @pytest.mark.massage
1398 def test_do_massage_basic(self):
1399 def converter(draft_f, **kwargs):
1400 return Part("text", "plain", draft_f.path, orig=True)
1402 with File() as draft_f, File() as cmd_f:
1406 converter=converter,
1408 lines = cmd_f.read().splitlines()
1410 assert "send-message" in lines.pop(0)
1411 assert "update-encoding" in lines.pop(0)
1412 assert "first-entry" in lines.pop(0)
1413 assert "source 'rm -f " in lines.pop(0)
1414 assert '="$my_editor"' in lines.pop(0)
1415 assert '="$my_edit_headers"' in lines.pop(0)
1416 assert "unset my_editor" == lines.pop(0)
1417 assert "unset my_edit_headers" == lines.pop(0)
1418 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
1420 @pytest.mark.massage
1421 def test_do_massage_fulltree(self, mime_tree_related_to_alternative):
1422 def converter(draft_f, **kwargs):
1423 return mime_tree_related_to_alternative
1427 with File() as draft_f, File() as cmd_f:
1431 max_other_attachments=max_attachments,
1432 converter=converter,
1434 lines = cmd_f.read().splitlines()[:-6]
1436 assert "first-entry" in lines.pop()
1437 assert "update-encoding" in lines.pop()
1438 assert "Plain" in lines.pop()
1439 assert "part.html" in lines.pop()
1440 assert "toggle-unlink" in lines.pop()
1441 for i in range(max_attachments):
1442 assert "move-up" in lines.pop()
1443 assert "move-down" in lines.pop()
1444 assert "HTML" in lines.pop()
1445 assert "jump>1" in lines.pop()
1446 assert "jump>2" in lines.pop()
1447 assert "group-alternatives" in lines.pop()
1448 assert "Alternative" in lines.pop()
1449 assert "logo.png" in lines.pop()
1450 assert "toggle-unlink" in lines.pop()
1451 assert "content-id" in lines.pop()
1452 for i in range(max_attachments):
1453 assert "move-up" in lines.pop()
1454 assert "move-down" in lines.pop()
1455 assert "Logo" in lines.pop()
1456 assert "jump>1" in lines.pop()
1457 assert "jump>4" in lines.pop()
1458 assert "group-related" in lines.pop()
1459 assert "Related" in lines.pop()
1460 assert "send-message" in lines.pop()
1461 assert len(lines) == 0
1463 @pytest.mark.massage
1464 def test_mime_tree_relative_within_alternative(
1465 self, mime_tree_related_to_html
1467 def converter(draft_f, **kwargs):
1468 return mime_tree_related_to_html
1470 with File() as draft_f, File() as cmd_f:
1474 converter=converter,
1476 lines = cmd_f.read().splitlines()[:-6]
1478 assert "first-entry" in lines.pop()
1479 assert "update-encoding" in lines.pop()
1480 assert "Plain" in lines.pop()
1481 assert "part.html" in lines.pop()
1482 assert "toggle-unlink" in lines.pop()
1483 assert "move-up" in lines.pop()
1486 if "move-up" not in top:
1488 assert "move-down" in top
1489 assert "HTML" in lines.pop()
1490 assert "logo.png" in lines.pop()
1491 assert "toggle-unlink" in lines.pop()
1492 assert "content-id" in lines.pop()
1493 assert "move-up" in lines.pop()
1496 if "move-up" not in top:
1498 assert "move-down" in top
1499 assert "move-down" in lines.pop()
1500 assert "Logo" in lines.pop()
1501 assert "jump>2" in lines.pop()
1502 assert "jump>3" in lines.pop()
1503 assert "group-related" in lines.pop()
1504 assert "Related" in lines.pop()
1505 assert "jump>1" in lines.pop()
1506 assert "jump>2" in lines.pop()
1507 assert "group-alternative" in lines.pop()
1508 assert "Alternative" in lines.pop()
1509 assert "send-message" in lines.pop()
1510 assert len(lines) == 0
1512 @pytest.mark.massage
1513 def test_mime_tree_nested_trees_does_not_break_positioning(
1514 self, mime_tree_nested
1516 def converter(draft_f, **kwargs):
1517 return mime_tree_nested
1519 with File() as draft_f, File() as cmd_f:
1523 converter=converter,
1525 lines = cmd_f.read().splitlines()
1527 while "logo.png" not in lines.pop():
1530 assert "content-id" in lines.pop()
1531 assert "move-up" in lines.pop()
1534 if "move-up" not in top:
1536 assert "move-down" in top
1537 # Due to the nested trees, the number of descendents of the sibling
1538 # actually needs to be considered, not just the nieces. So to move
1539 # from position 1 to position 6, it only needs one <move-down>
1540 # because that jumps over the entire sibling tree. Thus what
1541 # follows next must not be another <move-down>
1542 assert "Logo" in lines.pop()
1544 @pytest.mark.converter
1545 def test_converter_tree_basic(self, fakepath, const1, fakefilefactory):
1546 with fakefilefactory(fakepath, content=const1) as draft_f:
1547 tree = convert_markdown_to_html(
1548 draft_f, filefactory=fakefilefactory
1551 assert tree.subtype == "alternative"
1552 assert len(tree.children) == 2
1553 assert tree.children[0].subtype == "plain"
1554 assert tree.children[0].path == draft_f.path
1555 assert tree.children[0].orig
1556 assert tree.children[1].subtype == "html"
1557 assert tree.children[1].path == fakepath.with_suffix(".html")
1559 @pytest.mark.converter
1560 def test_converter_writes(
1561 self, fakepath, fakefilefactory, const1, monkeypatch
1563 with fakefilefactory(fakepath, content=const1) as draft_f:
1564 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1566 html = fakefilefactory.pop()
1567 assert fakepath.with_suffix(".html") == html[0]
1568 assert const1 in html[1].read()
1569 text = fakefilefactory.pop()
1570 assert fakepath == text[0]
1571 assert const1 == text[1].read()
1573 @pytest.mark.imgproc
1574 def test_markdown_inline_image_processor(self):
1575 imgpath1 = "file:/path/to/image.png"
1576 imgpath2 = "file:///path/to/image.png?url=params"
1577 imgpath3 = "/path/to/image.png"
1578 text = f"""![inline local image]({imgpath1})
1580 with newline]({imgpath2})
1581 ![image local path]({imgpath3})"""
1582 text, html, images, mdwn = markdown_with_inline_image_support(text)
1584 # local paths have been normalised to URLs:
1585 imgpath3 = f"file://{imgpath3}"
1587 assert 'src="cid:' in html
1588 assert "](cid:" in text
1589 assert len(images) == 3
1590 assert imgpath1 in images
1591 assert imgpath2 in images
1592 assert imgpath3 in images
1593 assert images[imgpath1].cid != images[imgpath2].cid
1594 assert images[imgpath1].cid != images[imgpath3].cid
1595 assert images[imgpath2].cid != images[imgpath3].cid
1597 @pytest.mark.imgproc
1598 def test_markdown_inline_image_processor_title_to_desc(self, const1):
1599 imgpath = "file:///path/to/image.png"
1600 text = f'![inline local image]({imgpath} "{const1}")'
1601 text, html, images, mdwn = markdown_with_inline_image_support(text)
1602 assert images[imgpath].desc == const1
1604 @pytest.mark.imgproc
1605 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
1606 imgpath = "file:///path/to/image.png"
1607 text = f"![{const1}]({imgpath})"
1608 text, html, images, mdwn = markdown_with_inline_image_support(text)
1609 assert images[imgpath].desc == const1
1611 @pytest.mark.imgproc
1612 def test_markdown_inline_image_processor_title_over_alt_desc(
1613 self, const1, const2
1615 imgpath = "file:///path/to/image.png"
1616 text = f'![{const1}]({imgpath} "{const2}")'
1617 text, html, images, mdwn = markdown_with_inline_image_support(text)
1618 assert images[imgpath].desc == const2
1620 @pytest.mark.imgproc
1621 def test_markdown_inline_image_not_external(self):
1622 imgpath = "https://path/to/image.png"
1623 text = f"![inline image]({imgpath})"
1624 text, html, images, mdwn = markdown_with_inline_image_support(text)
1626 assert 'src="cid:' not in html
1627 assert "](cid:" not in text
1628 assert len(images) == 0
1630 @pytest.mark.imgproc
1631 def test_markdown_inline_image_local_file(self):
1632 imgpath = "/path/to/image.png"
1633 text = f"![inline image]({imgpath})"
1634 text, html, images, mdwn = markdown_with_inline_image_support(text)
1636 for k, v in images.items():
1637 assert k == f"file://{imgpath}"
1640 @pytest.mark.imgproc
1641 def test_markdown_inline_image_expanduser(self):
1642 imgpath = pathlib.Path("~/image.png")
1643 text = f"![inline image]({imgpath})"
1644 text, html, images, mdwn = markdown_with_inline_image_support(text)
1646 for k, v in images.items():
1647 assert k == f"file://{imgpath.expanduser()}"
1653 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1654 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
1657 @pytest.mark.imgproc
1658 def test_markdown_inline_image_processor_base64(self, test_png):
1659 text = f"![1px white inlined]({test_png})"
1660 text, html, images, mdwn = markdown_with_inline_image_support(text)
1662 assert 'src="cid:' in html
1663 assert "](cid:" in text
1664 assert len(images) == 1
1665 assert test_png in images
1667 @pytest.mark.converter
1668 def test_converter_tree_inline_image_base64(
1669 self, test_png, fakefilefactory
1671 text = f"![inline base64 image]({test_png})"
1672 with fakefilefactory(content=text) as draft_f:
1673 tree = convert_markdown_to_html(
1675 filefactory=fakefilefactory,
1676 related_to_html_only=False,
1678 assert tree.subtype == "relative"
1679 assert tree.children[0].subtype == "alternative"
1680 assert tree.children[1].subtype == "png"
1681 written = fakefilefactory.pop()
1682 assert tree.children[1].path == written[0]
1683 assert b"PNG" in written[1].read()
1685 @pytest.mark.converter
1686 def test_converter_tree_inline_image_base64_related_to_html(
1687 self, test_png, fakefilefactory
1689 text = f"![inline base64 image]({test_png})"
1690 with fakefilefactory(content=text) as draft_f:
1691 tree = convert_markdown_to_html(
1693 filefactory=fakefilefactory,
1694 related_to_html_only=True,
1696 assert tree.subtype == "alternative"
1697 assert tree.children[1].subtype == "relative"
1698 assert tree.children[1].children[1].subtype == "png"
1699 written = fakefilefactory.pop()
1700 assert tree.children[1].children[1].path == written[0]
1701 assert b"PNG" in written[1].read()
1703 @pytest.mark.converter
1704 def test_converter_tree_inline_image_cid(
1705 self, const1, fakefilefactory
1707 text = f"![inline base64 image](cid:{const1})"
1708 with fakefilefactory(content=text) as draft_f:
1709 tree = convert_markdown_to_html(
1711 filefactory=fakefilefactory,
1712 related_to_html_only=False,
1714 assert len(tree.children) == 2
1715 assert tree.children[0].cid != const1
1716 assert tree.children[0].type != "image"
1717 assert tree.children[1].cid != const1
1718 assert tree.children[1].type != "image"
1721 def fakefilefactory(self):
1722 return FakeFileFactory()
1724 @pytest.mark.imgcoll
1725 def test_inline_image_collection(
1726 self, test_png, const1, const2, fakefilefactory
1728 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1729 relparts = collect_inline_images(
1730 test_images, filefactory=fakefilefactory
1733 written = fakefilefactory.pop()
1734 assert b"PNG" in written[1].read()
1736 assert relparts[0].subtype == "png"
1737 assert relparts[0].path == written[0]
1738 assert relparts[0].cid == const1
1739 assert const2 in relparts[0].desc
1743 @pytest.mark.styling
1744 def test_apply_stylesheet(self):
1745 html = "<p>Hello, world!</p>"
1746 css = "p { color:red }"
1747 out = apply_styling(html, css)
1748 assert 'p style="color' in out
1750 @pytest.mark.styling
1751 def test_apply_no_stylesheet(self, const1):
1752 out = apply_styling(const1, None)
1754 @pytest.mark.massage
1755 @pytest.mark.styling
1756 def test_massage_styling_to_converter(self):
1757 css = "p { color:red }"
1760 def converter(draft_f, css_f, **kwargs):
1762 css_applied.append(css)
1763 return Part("text", "plain", draft_f.path, orig=True)
1767 File(mode="w") as cmd_f,
1768 File(content=css) as css_f
1774 converter=converter,
1776 assert css_applied[0] == css
1778 @pytest.mark.converter
1779 @pytest.mark.styling
1780 def test_converter_apply_styles(
1781 self, const1, monkeypatch, fakepath, fakefilefactory
1783 css = "p { color:red }"
1785 monkeypatch.context() as m,
1786 fakefilefactory(fakepath, content=const1) as draft_f,
1787 fakefilefactory(content=css) as css_f,
1792 lambda s, t: f"<p>{t}</p>",
1794 convert_markdown_to_html(
1795 draft_f, css_f=css_f, filefactory=fakefilefactory
1799 fakefilefactory[fakepath.with_suffix(".html")].read(),
1804 @pytest.mark.styling
1805 def test_apply_stylesheet_pygments(self):
1807 f'<div class="{_CODEHILITE_CLASS}">'
1808 "<pre>def foo():\n return</pre></div>"
1810 out = apply_styling(html, _PYGMENTS_CSS)
1811 assert f'{_CODEHILITE_CLASS}" style="' in out
1814 def test_signature_extraction_no_signature(self, const1):
1815 assert (const1, None, None) == extract_signature(const1)
1818 def test_signature_extraction_just_text(self, const1, const2):
1819 origtext, textsig, htmlsig = extract_signature(
1820 f"{const1}{EMAIL_SIG_SEP}{const2}"
1822 assert origtext == const1
1823 assert textsig == const2
1824 assert htmlsig is None
1827 def test_signature_extraction_html(
1828 self, fakepath, fakefilefactory, const1, const2
1830 sigconst = "HTML signature from {path} but as a string"
1831 sig = f'<div id="signature">{sigconst.format(path=fakepath)}</div>'
1833 sig_f = fakefilefactory(fakepath, content=sig)
1835 origtext, textsig, htmlsig = extract_signature(
1836 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER} {fakepath}\n{const2}",
1837 filefactory=fakefilefactory,
1839 assert origtext == const1
1840 assert textsig == const2
1841 assert htmlsig == sigconst.format(path=fakepath)
1844 def test_signature_extraction_file_not_found(self, fakepath, const1):
1845 with pytest.raises(FileNotFoundError):
1846 origtext, textsig, htmlsig = extract_signature(
1847 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER}{fakepath}\n{const1}"
1850 @pytest.mark.imgproc
1851 def test_image_registry(self, const1):
1852 reg = ImageRegistry()
1853 cid = reg.register(const1)
1855 assert not cid.startswith("<")
1856 assert not cid.endswith(">")
1857 assert const1 in reg
1859 @pytest.mark.imgproc
1860 def test_image_registry_file_uri(self, const1):
1861 reg = ImageRegistry()
1862 reg.register("/some/path")
1864 assert path.startswith("file://")
1867 @pytest.mark.converter
1869 def test_converter_signature_handling(
1870 self, fakepath, fakefilefactory, monkeypatch
1873 "This is the mail body\n",
1875 "This is a plain-text signature only",
1880 fakepath, content="".join(mailparts)
1882 monkeypatch.context() as m,
1884 m.setattr(markdown.Markdown, "convert", lambda s, t: t)
1885 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1887 soup = bs4.BeautifulSoup(
1888 fakefilefactory[fakepath.with_suffix(".html")].read(),
1891 body = soup.body.contents
1893 assert mailparts[0] in body.pop(0)
1895 sig = soup.select_one("#signature")
1896 assert sig == body.pop(0)
1898 sep = sig.select_one("span.sig_separator")
1899 assert sep == sig.contents[0]
1900 assert f"\n{sep.text}\n" == EMAIL_SIG_SEP
1902 assert mailparts[2] in sig.contents[1]
1904 @pytest.mark.converter
1906 def test_converter_signature_handling_htmlsig(
1907 self, fakepath, fakepath2, fakefilefactory, monkeypatch
1910 "This is the mail body",
1912 f"{HTML_SIG_MARKER}{fakepath2}\n",
1913 "This is the plain-text version",
1915 htmlsig = "HTML Signature from {path} but as a string"
1917 f'<div id="signature"><p>{htmlsig.format(path=fakepath2)}</p></div>'
1920 sig_f = fakefilefactory(fakepath2, content=html)
1927 fakepath, content="".join(mailparts)
1929 monkeypatch.context() as m,
1932 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1934 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1936 soup = bs4.BeautifulSoup(
1937 fakefilefactory[fakepath.with_suffix(".html")].read(),
1940 sig = soup.select_one("#signature")
1943 assert HTML_SIG_MARKER not in sig.text
1944 assert htmlsig.format(path=fakepath2) == sig.text.strip()
1946 plaintext = fakefilefactory[fakepath].read()
1947 assert plaintext.endswith(EMAIL_SIG_SEP + mailparts[-1])
1949 @pytest.mark.converter
1951 def test_converter_signature_handling_htmlsig_with_image(
1952 self, fakepath, fakepath2, fakefilefactory, monkeypatch, test_png
1955 "This is the mail body",
1957 f"{HTML_SIG_MARKER}{fakepath2}\n",
1958 "This is the plain-text version",
1961 "HTML Signature from {path} with image\n"
1962 f'<img src="{test_png}">\n'
1965 f'<div id="signature">{htmlsig.format(path=fakepath2)}</div>'
1968 sig_f = fakefilefactory(fakepath2, content=html)
1975 fakepath, content="".join(mailparts)
1977 monkeypatch.context() as m,
1980 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1982 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1984 assert fakefilefactory.pop()[0].suffix == ".png"
1986 soup = bs4.BeautifulSoup(
1987 fakefilefactory[fakepath.with_suffix(".html")].read(),
1990 assert soup.img.attrs["src"].startswith("cid:")
1992 @pytest.mark.converter
1994 def test_converter_signature_handling_textsig_with_image(
1995 self, fakepath, fakefilefactory, test_png
1998 "This is the mail body",
2000 "This is the plain-text version with image\n",
2001 f"![Inline]({test_png})",
2005 fakepath, content="".join(mailparts)
2008 tree = convert_markdown_to_html(
2009 draft_f, filefactory=fakefilefactory
2012 assert tree.subtype == "relative"
2013 assert tree.children[0].subtype == "alternative"
2014 assert tree.children[1].subtype == "png"
2015 written = fakefilefactory.pop()
2016 assert tree.children[1].path == written[0]
2017 assert written[1].read() == request.urlopen(test_png).read()
2019 @pytest.mark.converter
2020 def test_converter_attribution_to_admonition(
2021 self, fakepath, fakefilefactory
2024 "Regarding whatever",
2025 "> blockquote line1",
2026 "> blockquote line2",
2028 "> new para with **bold** text",
2030 with fakefilefactory(
2031 fakepath, content="\n".join(mailparts)
2033 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2035 soup = bs4.BeautifulSoup(
2036 fakefilefactory[fakepath.with_suffix(".html")].read(),
2039 quote = soup.select_one("div.admonition.quote")
2042 soup.select_one("p.admonition-title").extract().text.strip()
2046 p = quote.p.extract()
2047 assert p.text.strip() == "\n".join(p[2:] for p in mailparts[1:3])
2049 p = quote.p.extract()
2050 assert p.contents[1].name == "strong"
2052 @pytest.mark.converter
2053 def test_converter_attribution_to_admonition_with_blockquote(
2054 self, fakepath, fakefilefactory
2057 "Regarding whatever",
2058 "> blockquote line1",
2059 "> blockquote line2",
2061 "> new para with **bold** text",
2063 with fakefilefactory(
2064 fakepath, content="\n".join(mailparts)
2066 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2068 soup = bs4.BeautifulSoup(
2069 fakefilefactory[fakepath.with_suffix(".html")].read(),
2072 quote = soup.select_one("div.admonition.quote")
2073 assert quote.blockquote
2075 @pytest.mark.converter
2076 def test_converter_attribution_to_admonition_multiple(
2077 self, fakepath, fakefilefactory
2080 "Regarding whatever",
2081 "> blockquote line1",
2082 "> blockquote line2",
2086 "> continued emailquote",
2088 "Another email-quote",
2091 with fakefilefactory(
2092 fakepath, content="\n".join(mailparts)
2094 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2096 soup = bs4.BeautifulSoup(
2097 fakefilefactory[fakepath.with_suffix(".html")].read(),
2100 quote = soup.select_one("div.admonition.quote.continued").extract()
2103 quote.select_one("p.admonition-title").extract().text.strip()
2107 p = quote.p.extract()
2110 quote = soup.select_one("div.admonition.quote.continued").extract()
2113 quote.select_one("p.admonition-title").extract().text.strip()
2118 def test_file_class_contextmanager(self, const1, monkeypatch):
2119 state = dict(o=False, c=False)
2124 with monkeypatch.context() as m:
2125 m.setattr(File, "open", lambda s: fn("o"))
2126 m.setattr(File, "close", lambda s: fn("c"))
2129 assert not state["c"]
2133 def test_file_class_no_path(self, const1):
2134 with File(mode="w+") as f:
2135 f.write(const1, cache=False)
2136 assert f.read(cache=False) == const1
2139 def test_file_class_path(self, const1, tmp_path):
2140 with File(tmp_path / "file", mode="w+") as f:
2141 f.write(const1, cache=False)
2142 assert f.read(cache=False) == const1
2145 def test_file_class_path_no_exists(self, fakepath):
2146 with pytest.raises(FileNotFoundError):
2147 File(fakepath, mode="r").open()
2150 def test_file_class_cache(self, tmp_path, const1, const2):
2151 path = tmp_path / "file"
2152 file = File(path, mode="w+")
2154 f.write(const1, cache=True)
2155 with open(path, mode="w") as f:
2158 assert f.read(cache=True) == const1
2161 def test_file_class_cache_init(self, const1):
2162 file = File(path=None, mode="r", content=const1)
2164 assert f.read() == const1
2167 def test_file_class_content_or_path(self, fakepath, const1):
2168 with pytest.raises(RuntimeError):
2169 file = File(path=fakepath, content=const1)
2172 def test_file_class_content_needs_read(self, const1):
2173 with pytest.raises(RuntimeError):
2174 file = File(mode="w", content=const1)
2177 def test_file_class_write_persists_close(self, const1):
2182 assert f.read() == const1
2185 def test_file_class_write_resets_read_cache(self, const1, const2):
2186 with File(mode="w+", content=const1) as f:
2187 assert f.read() == const1
2189 assert f.read() == const2
2192 def test_file_factory(self):
2193 fact = FileFactory()
2195 assert isinstance(f, File)
2196 assert len(fact) == 1
2201 def test_fake_file_factory(self, fakepath, fakefilefactory):
2202 fact = FakeFileFactory()
2203 f = fakefilefactory(fakepath)
2204 assert f.path == fakepath
2205 assert f == fakefilefactory[fakepath]
2208 def test_fake_file_factory_path_persistence(
2209 self, fakepath, fakefilefactory
2211 f1 = fakefilefactory(fakepath)
2212 assert f1 == fakefilefactory(fakepath)