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)
236 return f.read(cache=cache)
238 if self._lastop == File.Op.W:
241 except io.UnsupportedOperation:
244 self._lastop = File.Op.R
247 self._add_to_cache(File.Op.R, self._file.read())
248 return self._get_cache(File.Op.R)
250 return self._file.read()
252 def write(self, s, *, cache=True):
256 return f.write(s, cache=cache)
258 if self._lastop == File.Op.R:
261 except io.UnsupportedOperation:
265 self._add_to_cache(File.Op.W, s)
267 self._cache[File.Op.R] = self._cache[File.Op.W]
269 written = self._file.write(s)
271 self._lastop = File.Op.W
274 path = property(lambda s: s._path)
278 f'<File path={self._path or "(buffered)"} open={bool(self._file)} '
279 f"rcache={sum(len(c) for c in self._rcache) if self._rcache is not None else False} "
280 f"wcache={sum(len(c) for c in self._wcache) if self._wcache is not None else False}>"
288 def __call__(self, path=None, mode="r", content=None, **kwargs):
289 f = File(path, mode, content, **kwargs)
290 self._files.append(f)
294 return self._files.__len__()
296 def pop(self, idx=-1):
297 return self._files.pop(idx)
299 def __getitem__(self, idx):
300 return self._files.__getitem__(idx)
302 def __contains__(self, f):
303 return self._files.__contains__(f)
306 class FakeFileFactory(FileFactory):
309 self._paths2files = OrderedDict()
311 def __call__(self, path=None, mode="r", content=None, **kwargs):
312 if path in self._paths2files:
313 return self._paths2files[path]
315 f = super().__call__(None, mode, content, **kwargs)
316 self._paths2files[path] = f
320 class FakeFile(File):
323 # this is quality Python! We do this so that the fake file, which has
324 # no path, fake-pretends to have a path for testing purposes.
326 f.__class__ = FakeFile
329 def __getitem__(self, path):
330 return self._paths2files.__getitem__(path)
332 def get(self, path, default):
333 return self._paths2files.get(path, default)
335 def pop(self, last=True):
336 return self._paths2files.popitem(last)
340 f"<FakeFileFactory nfiles={len(self._files)} "
341 f"paths={len(self._paths2files)}>"
345 # [ IMAGE HANDLING ] ##########################################################
348 InlineImageInfo = namedtuple(
349 "InlineImageInfo", ["cid", "desc"], defaults=[None]
355 self._images = OrderedDict()
357 def register(self, path, description=None):
358 # path = str(pathlib.Path(path).expanduser())
359 path = os.path.expanduser(path)
360 if path.startswith("/"):
361 path = f"file://{path}"
362 cid = make_msgid()[1:-1]
363 self._images[path] = InlineImageInfo(cid, description)
367 return self._images.__iter__()
369 def __getitem__(self, idx):
370 return self._images.__getitem__(idx)
373 return self._images.__len__()
376 return self._images.items()
379 return f"<ImageRegistry(items={len(self._images)})>"
382 return self._images.__str__()
385 class InlineImageExtension(Extension):
386 class RelatedImageInlineProcessor(ImageInlineProcessor):
387 def __init__(self, re, md, registry):
388 super().__init__(re, md)
389 self._registry = registry
391 def handleMatch(self, m, data):
392 el, start, end = super().handleMatch(m, data)
393 if "src" in el.attrib:
394 src = el.attrib["src"]
395 if "://" not in src or src.startswith("file://"):
396 # We only inline local content
397 cid = self._registry.register(
399 el.attrib.get("title", el.attrib.get("alt")),
401 el.attrib["src"] = f"cid:{cid}"
402 return el, start, end
404 def __init__(self, registry):
406 self._image_registry = registry
408 INLINE_PATTERN_NAME = "image_link"
410 def extendMarkdown(self, md):
411 md.registerExtension(self)
412 inline_image_proc = self.RelatedImageInlineProcessor(
413 IMAGE_LINK_RE, md, self._image_registry
415 md.inlinePatterns.register(
416 inline_image_proc, InlineImageExtension.INLINE_PATTERN_NAME, 150
420 def markdown_with_inline_image_support(
426 extension_configs=None,
429 image_registry if image_registry is not None else ImageRegistry()
431 inline_image_handler = InlineImageExtension(registry=registry)
432 extensions = extensions or []
433 extensions.append(inline_image_handler)
434 mdwn = markdown.Markdown(
435 extensions=extensions, extension_configs=extension_configs
438 htmltext = mdwn.convert(text)
440 def replace_image_with_cid(matchobj):
441 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
443 return f"(cid:{registry[m].cid}"
444 return matchobj.group(0)
446 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
447 return text, htmltext, registry, mdwn
450 # [ CSS STYLING ] #############################################################
462 from pygments.formatters import get_formatter_by_name
464 _CODEHILITE_CLASS = "codehilite"
466 _PYGMENTS_CSS = get_formatter_by_name(
467 "html", style="default"
468 ).get_style_defs(f".{_CODEHILITE_CLASS}")
474 def apply_styling(html, css):
478 .with_cssString("\n".join(s for s in [_PYGMENTS_CSS, css] if s))
483 # [ QUOTE HANDLING ] ##########################################################
486 class QuoteToAdmonitionExtension(Extension):
487 class EmailQuoteBlockProcessor(BlockProcessor):
488 RE = re.compile(r"(?:^|\n)>\s*(.*)")
490 def __init__(self, parser):
491 super().__init__(parser)
494 def test(self, parent, blocks):
495 if markdown.util.nearing_recursion_limit():
498 lines = blocks.splitlines()
503 elif not self.RE.search(lines[0]):
506 return len(lines) > 0
508 elif not self.RE.search(lines[0]) and self.RE.search(lines[1]):
511 elif self._title and self.RE.search(lines[1]):
516 def run(self, parent, blocks):
517 quotelines = blocks.pop(0).splitlines()
519 cont = bool(self._title)
520 if not self.RE.search(quotelines[0]):
521 self._title = quotelines.pop(0)
523 admonition = etree.SubElement(parent, "div")
525 "class", f"admonition quote{' continued' if cont else ''}"
527 self.parser.parseChunk(admonition, self._title)
529 admonition[0].set("class", "admonition-title")
530 self.parser.parseChunk(
531 admonition, "\n".join(self.clean(line) for line in quotelines)
535 def clean(klass, line):
536 m = klass.RE.match(line)
537 return m.group(1) if m else line
539 def extendMarkdown(self, md):
540 md.registerExtension(self)
541 email_quote_proc = self.EmailQuoteBlockProcessor(md.parser)
542 md.parser.blockprocessors.register(email_quote_proc, "emailquote", 25)
545 # [ PARTS GENERATION ] ########################################################
551 ["type", "subtype", "path", "desc", "cid", "orig"],
552 defaults=[None, None, False],
556 ret = f"<{self.type}/{self.subtype}>"
558 ret = f"{ret} cid:{self.cid}"
560 ret = f"{ret} ORIGINAL"
565 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
568 return f"<multipart/{self.subtype}> children={len(self.children)}"
571 return hash(str(self.subtype) + "".join(str(self.children)))
574 def collect_inline_images(
575 image_registry, *, tempdir=None, filefactory=FileFactory()
578 for path, info in image_registry.items():
579 if path.startswith("cid:"):
582 data = request.urlopen(path)
584 mimetype = data.headers["Content-Type"]
585 ext = mimetypes.guess_extension(mimetype)
586 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
587 path = pathlib.Path(tempfilename[1])
589 with filefactory(path, "w+b") as out_f:
590 out_f.write(data.read())
592 # filewriter_fn(path, data.read(), "w+b")
595 f'Inline image: "{info.desc}"'
597 else f"Inline image {str(len(relparts)+1)}"
600 Part(*mimetype.split("/"), path, cid=info.cid, desc=desc)
606 EMAIL_SIG_SEP = "\n-- \n"
607 HTML_SIG_MARKER = "=htmlsig "
610 def make_html_doc(body, sig=None):
615 '<meta http-equiv="content-type" content="text/html; charset=UTF-8">\n' # noqa: E501
616 '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n' # noqa: E501
625 f'{ret}<div id="signature"><span class="sig_separator">{EMAIL_SIG_SEP.strip(nl)}</span>\n' # noqa: E501
630 return f"{ret}\n </body>\n</html>"
633 def make_text_mail(text, sig=None):
634 return EMAIL_SIG_SEP.join((text, sig)) if sig else text
637 def extract_signature(text, *, filefactory=FileFactory()):
638 parts = text.split(EMAIL_SIG_SEP, 1)
640 return text, None, None
642 lines = parts[1].splitlines()
643 if lines[0].startswith(HTML_SIG_MARKER):
644 path = pathlib.Path(re.split(r" +", lines.pop(0), maxsplit=1)[1])
645 textsig = "\n".join(lines)
647 sig_input = filefactory(path.expanduser()).read()
648 soup = bs4.BeautifulSoup(sig_input, "html.parser")
650 style = str(soup.style.extract()) if soup.style else ""
651 for sig_selector in (
661 sig = soup.select_one(sig_selector)
666 return parts[0], textsig, style + sig_input
668 if sig.attrs.get("id") == "signature":
669 sig = "".join(str(c) for c in sig.children)
671 return parts[0], textsig, style + str(sig)
673 return parts[0], parts[1], None
676 def convert_markdown_to_html(
679 related_to_html_only=False,
682 filefactory=FileFactory(),
685 extension_configs=None,
687 # TODO extension_configs need to be handled differently
688 extension_configs = extension_configs or {}
689 extension_configs.setdefault("pymdownx.highlight", {})[
691 ] = _CODEHILITE_CLASS
693 extensions = extensions or []
694 extensions.append(QuoteToAdmonitionExtension())
696 draft = draft_f.read()
697 origtext, textsig, htmlsig = extract_signature(
698 draft, filefactory=filefactory
706 ) = markdown_with_inline_image_support(
707 origtext, extensions=extensions, extension_configs=extension_configs
712 # TODO: decide what to do if there is no plain-text version
713 raise NotImplementedError("HTML signature but no text alternative")
715 soup = bs4.BeautifulSoup(htmlsig, "html.parser")
716 for img in soup.find_all("img"):
717 uri = img.attrs["src"]
718 desc = img.attrs.get("title", img.attrs.get("alt"))
719 cid = image_registry.register(uri, desc)
720 img.attrs["src"] = f"cid:{cid}"
730 ) = markdown_with_inline_image_support(
732 extensions=extensions,
733 extension_configs=extension_configs,
734 image_registry=image_registry,
738 origtext = make_text_mail(origtext, textsig)
739 draft_f.write(origtext)
741 "text", "plain", draft_f.path, "Plain-text version", orig=True
744 htmltext = make_html_doc(htmltext, htmlsig)
745 htmltext = apply_styling(htmltext, css_f.read() if css_f else None)
748 htmlpath = draft_f.path.with_suffix(".html")
750 htmlpath = pathlib.Path(
751 tempfile.mkstemp(suffix=".html", dir=tempdir)[1]
754 htmlpath, "w", encoding="utf-8", errors="xmlcharrefreplace"
756 out_f.write(htmltext)
757 htmlpart = Part("text", "html", htmlpath, "HTML version")
760 htmldump_f.write(htmltext)
762 imgparts = collect_inline_images(
763 image_registry, tempdir=tempdir, filefactory=filefactory
766 if related_to_html_only:
767 # If there are inline image part, they will be contained within a
768 # multipart/related part along with the HTML part only
770 # replace htmlpart with a multipart/related container of the HTML
771 # parts and the images
772 htmlpart = Multipart(
773 "relative", [htmlpart] + imgparts, "Group of related content"
777 "alternative", [textpart, htmlpart], "Group of alternative content"
781 # If there are inline image part, they will be siblings to the
782 # multipart/alternative tree within a multipart/related part
784 "alternative", [textpart, htmlpart], "Group of alternative content"
788 "relative", [altpart] + imgparts, "Group of related content"
794 class MIMETreeDFWalker:
795 def __init__(self, *, visitor_fn=None, debug=False):
796 self._visitor_fn = visitor_fn or self._echovisit
799 def _echovisit(self, node, ancestry, debugprint):
800 debugprint(f"node={node} ancestry={ancestry}")
802 def walk(self, root, *, visitor_fn=None):
804 Recursive function to implement a depth-dirst walk of the MIME-tree
807 if isinstance(root, list):
809 root = Multipart("mixed", children=root)
817 visitor_fn=visitor_fn or self._visitor_fn,
820 def _walk(self, node, *, ancestry, descendents, visitor_fn):
821 # Let's start by enumerating the parts at the current level. At the
822 # root level, ancestry will be the empty list, and we expect a
823 # multipart/* container at this level. Later, e.g. within a
824 # mutlipart/alternative container, the subtree will just be the
825 # alternative parts, while the top of the ancestry will be the
826 # multipart/alternative container, which we will process after the
829 lead = f"{'│ '*len(ancestry)}"
830 if isinstance(node, Multipart):
832 f"{lead}├{node} ancestry={[s.subtype for s in ancestry]}"
835 # Depth-first, so push the current container onto the ancestry
836 # stack, then descend …
837 ancestry.append(node)
838 self.debugprint(lead + "│ " * 2)
839 for child in node.children:
843 descendents=descendents,
844 visitor_fn=visitor_fn,
846 assert ancestry.pop() == node
847 sibling_descendents = descendents
848 descendents.extend(node.children)
851 self.debugprint(f"{lead}├{node}")
852 sibling_descendents = descendents
854 if False and ancestry:
855 self.debugprint(lead[:-1] + " │")
859 node, ancestry, sibling_descendents, debugprint=self.debugprint
862 def debugprint(self, s, **kwargs):
864 print(s, file=sys.stderr, **kwargs)
867 # [ RUN MODES ] ###############################################################
872 Stupid class to interface writing out Mutt commands. This is quite a hack
873 to deal with the fact that Mutt runs "push" commands in reverse order, so
874 all of a sudden, things become very complicated when mixing with "real"
877 Hence we keep two sets of commands, and one set of pushes. Commands are
878 added to the first until a push is added, after which commands are added to
879 the second set of commands.
881 On flush(), the first set is printed, followed by the pushes in reverse,
882 and then the second set is printed. All 3 sets are then cleared.
885 def __init__(self, out_f=sys.stdout, *, debug=False):
886 self._cmd1, self._push, self._cmd2 = [], [], []
898 s = s.replace('"', r"\"")
901 self._push.insert(0, s)
905 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
907 self._cmd1, self._push, self._cmd2 = [], [], []
909 def debugprint(self, s, **kwargs):
911 print(s, file=sys.stderr, **kwargs)
919 debug_commands=False,
921 temppath = temppath or pathlib.Path(
922 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
924 cmds = MuttCommands(out_f, debug=debug_commands)
926 editor = f"{' '.join(sys.argv)} massage --write-commands-to {temppath}"
928 cmds.cmd('set my_editor="$editor"')
929 cmds.cmd('set my_edit_headers="$edit_headers"')
930 cmds.cmd(f'set editor="{editor}"')
931 cmds.cmd("unset edit_headers")
932 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
933 cmds.push("<first-entry><edit-file>")
944 converter=convert_markdown_to_html,
945 related_to_html_only=True,
947 max_other_attachments=20,
949 debug_commands=False,
952 # Here's the big picture: we're being invoked as the editor on the email
953 # draft, and whatever commands we write to the file given as cmdpath will
954 # be run by the second source command in the macro definition.
956 # Let's start by cleaning up what the setup did (see above), i.e. we
957 # restore the $editor and $edit_headers variables, and also unset the
958 # variable used to identify the command file we're currently writing
960 cmds = MuttCommands(cmd_f, debug=debug_commands)
961 cmds.cmd('set editor="$my_editor"')
962 cmds.cmd('set edit_headers="$my_edit_headers"')
963 cmds.cmd("unset my_editor")
964 cmds.cmd("unset my_edit_headers")
966 # let's flush those commands, as there'll be a lot of pushes from now
967 # on, which need to be run in reverse order
970 extensions = extensions.split(",") if extensions else []
974 htmldump_f=htmldump_f,
975 related_to_html_only=related_to_html_only,
977 extensions=extensions,
980 mimetree = MIMETreeDFWalker(debug=debug_walk)
982 state = dict(pos=1, tags={}, parts=1)
984 def visitor_fn(item, ancestry, descendents, *, debugprint=None):
986 Visitor function called for every node (part) of the MIME tree,
987 depth-first, and responsible for telling NeoMutt how to assemble
990 KILL_LINE = r"\Ca\Ck"
992 if isinstance(item, Part):
993 # We've hit a leaf-node, i.e. an alternative or a related part
994 # with actual content.
998 # The original source already exists in the NeoMutt tree, but
999 # the underlying file may have been modified, so we need to
1000 # update the encoding, but that's it:
1001 cmds.push("<first-entry>")
1002 cmds.push("<update-encoding>")
1004 # We really just need to be able to assume that at this point,
1005 # NeoMutt is at position 1, and that we've processed only this
1006 # part so far. Nevermind about actual attachments, we can
1007 # safely ignore those as they stay at the end.
1008 assert state["pos"] == 1
1009 assert state["parts"] == 1
1011 # … whereas all other parts need to be added, and they're all
1012 # considered to be temporary and inline:
1013 cmds.push(f"<attach-file>{item.path}<enter>")
1014 cmds.push("<toggle-unlink><toggle-disposition>")
1016 # This added a part at the end of the list of parts, and that's
1017 # just how many parts we've seen so far, so it's position in
1018 # the NeoMutt compose list is the count of parts
1020 state["pos"] = state["parts"]
1022 # If the item (including the original) comes with additional
1023 # information, then we might just as well update the NeoMutt
1026 cmds.push(f"<edit-content-id>{KILL_LINE}{item.cid}<enter>")
1028 # Now for the biggest hack in this script, which is to handle
1029 # attachments, such as PDFs, that aren't related or alternatives.
1030 # The problem is that when we add an inline image, it always gets
1031 # appended to the list, i.e. inserted *after* other attachments.
1032 # Since we don't know the number of attachments, we also cannot
1033 # infer the postition of the new attachment. Therefore, we bubble
1034 # it all the way to the top, only to then move it down again:
1035 if state["pos"] > 1: # skip for the first part
1036 for i in range(max_other_attachments):
1037 # could use any number here, but has to be larger than the
1038 # number of possible attachments. The performance
1039 # difference of using a high number is negligible.
1040 # Bubble up the new part
1041 cmds.push("<move-up>")
1043 # As we push the part to the right position in the list (i.e.
1044 # the last of the subset of attachments this script added), we
1045 # must handle the situation that subtrees are skipped by
1046 # NeoMutt. Hence, the actual number of positions to move down
1047 # is decremented by the number of descendents so far
1049 for i in range(1, state["pos"] - len(descendents)):
1050 cmds.push("<move-down>")
1052 elif isinstance(item, Multipart):
1053 # This node has children, but we already visited them (see
1054 # above). The tags dictionary of State should contain a list of
1055 # their positions in the NeoMutt compose window, so iterate those
1056 # and tag the parts there:
1057 n_tags = len(state["tags"][item])
1058 for tag in state["tags"][item]:
1059 cmds.push(f"<jump>{tag}<enter><tag-entry>")
1061 if item.subtype == "alternative":
1062 cmds.push("<group-alternatives>")
1063 elif item.subtype in ("relative", "related"):
1064 cmds.push("<group-related>")
1065 elif item.subtype == "multilingual":
1066 cmds.push("<group-multilingual>")
1068 raise NotImplementedError(
1069 f"Handling of multipart/{item.subtype} is not implemented"
1072 state["pos"] -= n_tags - 1
1076 # We should never get here
1077 raise RuntimeError(f"Type {type(item)} is unexpected: {item}")
1079 # If the item has a description, we might just as well add it
1081 cmds.push(f"<edit-description>{KILL_LINE}{item.desc}<enter>")
1084 # If there's an ancestry, record the current (assumed) position in
1085 # the NeoMutt compose window as needed-to-tag by our direct parent
1086 # (i.e. the last item of the ancestry)
1087 state["tags"].setdefault(ancestry[-1], []).append(state["pos"])
1089 lead = "│ " * (len(ancestry) + 1) + "* "
1091 f"{lead}ancestry={[a.subtype for a in ancestry]}\n"
1092 f"{lead}descendents={[d.subtype for d in descendents]}\n"
1093 f"{lead}children_positions={state['tags'][ancestry[-1]]}\n"
1094 f"{lead}pos={state['pos']}, parts={state['parts']}"
1100 # Let's walk the tree and visit every node with our fancy visitor
1102 mimetree.walk(tree, visitor_fn=visitor_fn)
1105 cmds.push("<send-message>")
1107 # Finally, cleanup. Since we're responsible for removing the temporary
1108 # file, how's this for a little hack?
1110 filename = cmd_f.name
1111 except AttributeError:
1112 filename = "pytest_internal_file"
1113 cmds.cmd(f"source 'rm -f {filename}|'")
1114 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
1118 # [ CLI ENTRY ] ###############################################################
1120 if __name__ == "__main__":
1121 args = parse_cli_args()
1123 if args.mode is None:
1125 tempdir=args.tempdir,
1126 debug_commands=args.debug_commands,
1129 elif args.mode == "massage":
1131 File(args.MAILDRAFT, "r+") as draft_f,
1132 File(args.cmdpath, "w") as cmd_f,
1133 File(args.css_file, "r") as css_f,
1134 File(args.dump_html, "w") as htmldump_f,
1139 extensions=args.extensions,
1141 htmldump_f=htmldump_f,
1142 related_to_html_only=args.related_to_html_only,
1143 max_other_attachments=args.max_number_other_attachments,
1144 only_build=args.only_build,
1145 tempdir=args.tempdir,
1146 debug_commands=args.debug_commands,
1147 debug_walk=args.debug_walk,
1151 # [ TESTS ] ###################################################################
1159 return "Curvature Vest Usher Dividing+T#iceps Senior"
1163 return "Habitant Celestial 2litzy Resurf/ce Headpiece Harmonics"
1167 return pathlib.Path("/does/not/exist")
1170 def fakepath2(self):
1171 return pathlib.Path("/does/not/exist/either")
1173 # NOTE: tests using the capsys fixture must specify sys.stdout to the
1174 # functions they call, else old stdout is used and not captured
1176 @pytest.mark.muttctrl
1177 def test_MuttCommands_cmd(self, const1, const2, capsys):
1178 "Assert order of commands"
1179 cmds = MuttCommands(out_f=sys.stdout)
1183 captured = capsys.readouterr()
1184 assert captured.out == "\n".join((const1, const2, ""))
1186 @pytest.mark.muttctrl
1187 def test_MuttCommands_push(self, const1, const2, capsys):
1188 "Assert reverse order of pushes"
1189 cmds = MuttCommands(out_f=sys.stdout)
1193 captured = capsys.readouterr()
1196 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
1199 @pytest.mark.muttctrl
1200 def test_MuttCommands_push_escape(self, const1, const2, capsys):
1201 cmds = MuttCommands(out_f=sys.stdout)
1202 cmds.push(f'"{const1}"')
1204 captured = capsys.readouterr()
1205 assert f'"\\"{const1}\\""' in captured.out
1207 @pytest.mark.muttctrl
1208 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
1209 "Assert reverse order of pushes"
1210 cmds = MuttCommands(out_f=sys.stdout)
1211 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
1213 cmds.cmd(lines[4 * i + 0])
1214 cmds.cmd(lines[4 * i + 1])
1215 cmds.push(lines[4 * i + 2])
1216 cmds.push(lines[4 * i + 3])
1219 captured = capsys.readouterr()
1220 lines_out = captured.out.splitlines()
1221 assert lines[0] in lines_out[0]
1222 assert lines[1] in lines_out[1]
1223 assert lines[7] in lines_out[2]
1224 assert lines[6] in lines_out[3]
1225 assert lines[3] in lines_out[4]
1226 assert lines[2] in lines_out[5]
1227 assert lines[4] in lines_out[6]
1228 assert lines[5] in lines_out[7]
1231 def mime_tree_related_to_alternative(self):
1245 Part("text", "html", "part.html", desc="HTML"),
1250 "text", "png", "logo.png", cid="logo.png", desc="Logo"
1257 def mime_tree_related_to_html(self):
1271 Part("text", "html", "part.html", desc="HTML"),
1287 def mime_tree_nested(self):
1308 desc="Nested plain",
1317 desc="Nested alternative",
1333 @pytest.mark.treewalk
1334 def test_MIMETreeDFWalker_depth_first_walk(
1335 self, mime_tree_related_to_alternative
1337 mimetree = MIMETreeDFWalker()
1341 def visitor_fn(item, ancestry, descendents, debugprint):
1342 items.append((item, len(ancestry), len(descendents)))
1345 mime_tree_related_to_alternative, visitor_fn=visitor_fn
1347 assert len(items) == 5
1348 assert items[0][0].subtype == "plain"
1349 assert items[0][1] == 2
1350 assert items[0][2] == 0
1351 assert items[1][0].subtype == "html"
1352 assert items[1][1] == 2
1353 assert items[1][2] == 0
1354 assert items[2][0].subtype == "alternative"
1355 assert items[2][1] == 1
1356 assert items[2][2] == 2
1357 assert items[3][0].subtype == "png"
1358 assert items[3][1] == 1
1359 assert items[3][2] == 2
1360 assert items[4][0].subtype == "relative"
1361 assert items[4][1] == 0
1362 assert items[4][2] == 4
1364 @pytest.mark.treewalk
1365 def test_MIMETreeDFWalker_list_to_mixed(self, const1):
1366 mimetree = MIMETreeDFWalker()
1369 def visitor_fn(item, ancestry, descendents, debugprint):
1372 p = Part("text", "plain", const1)
1373 mimetree.walk([p], visitor_fn=visitor_fn)
1374 assert items[-1].subtype == "plain"
1375 mimetree.walk([p, p], visitor_fn=visitor_fn)
1376 assert items[-1].subtype == "mixed"
1378 @pytest.mark.treewalk
1379 def test_MIMETreeDFWalker_visitor_in_constructor(
1380 self, mime_tree_related_to_alternative
1384 def visitor_fn(item, ancestry, descendents, debugprint):
1387 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
1388 mimetree.walk(mime_tree_related_to_alternative)
1389 assert len(items) == 5
1392 def string_io(self, const1, text=None):
1393 return StringIO(text or const1)
1395 @pytest.mark.massage
1396 def test_do_massage_basic(self):
1397 def converter(draft_f, **kwargs):
1398 return Part("text", "plain", draft_f.path, orig=True)
1400 with File() as draft_f, File() as cmd_f:
1404 converter=converter,
1406 lines = cmd_f.read().splitlines()
1408 assert '="$my_editor"' in lines.pop(0)
1409 assert '="$my_edit_headers"' in lines.pop(0)
1410 assert "unset my_editor" == lines.pop(0)
1411 assert "unset my_edit_headers" == lines.pop(0)
1412 assert "send-message" in lines.pop(0)
1413 assert "update-encoding" in lines.pop(0)
1414 assert "first-entry" in lines.pop(0)
1415 assert "source 'rm -f " in lines.pop(0)
1416 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
1418 @pytest.mark.massage
1419 def test_do_massage_fulltree(self, mime_tree_related_to_alternative):
1420 def converter(draft_f, **kwargs):
1421 return mime_tree_related_to_alternative
1425 with File() as draft_f, File() as cmd_f:
1429 max_other_attachments=max_attachments,
1430 converter=converter,
1432 lines = cmd_f.read().splitlines()[4:-2]
1434 assert "first-entry" in lines.pop()
1435 assert "update-encoding" in lines.pop()
1436 assert "Plain" in lines.pop()
1437 assert "part.html" in lines.pop()
1438 assert "toggle-unlink" in lines.pop()
1439 for i in range(max_attachments):
1440 assert "move-up" in lines.pop()
1441 assert "move-down" in lines.pop()
1442 assert "HTML" in lines.pop()
1443 assert "jump>1" in lines.pop()
1444 assert "jump>2" in lines.pop()
1445 assert "group-alternatives" in lines.pop()
1446 assert "Alternative" in lines.pop()
1447 assert "logo.png" in lines.pop()
1448 assert "toggle-unlink" in lines.pop()
1449 assert "content-id" in lines.pop()
1450 for i in range(max_attachments):
1451 assert "move-up" in lines.pop()
1452 assert "move-down" in lines.pop()
1453 assert "Logo" in lines.pop()
1454 assert "jump>1" in lines.pop()
1455 assert "jump>4" in lines.pop()
1456 assert "group-related" in lines.pop()
1457 assert "Related" in lines.pop()
1458 assert "send-message" in lines.pop()
1459 assert len(lines) == 0
1461 @pytest.mark.massage
1462 def test_mime_tree_relative_within_alternative(
1463 self, mime_tree_related_to_html
1465 def converter(draft_f, **kwargs):
1466 return mime_tree_related_to_html
1468 with File() as draft_f, File() as cmd_f:
1472 converter=converter,
1474 lines = cmd_f.read().splitlines()[4:-2]
1476 assert "first-entry" in lines.pop()
1477 assert "update-encoding" in lines.pop()
1478 assert "Plain" in lines.pop()
1479 assert "part.html" in lines.pop()
1480 assert "toggle-unlink" 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 "HTML" in lines.pop()
1488 assert "logo.png" in lines.pop()
1489 assert "toggle-unlink" in lines.pop()
1490 assert "content-id" in lines.pop()
1491 assert "move-up" in lines.pop()
1494 if "move-up" not in top:
1496 assert "move-down" in top
1497 assert "move-down" in lines.pop()
1498 assert "Logo" in lines.pop()
1499 assert "jump>2" in lines.pop()
1500 assert "jump>3" in lines.pop()
1501 assert "group-related" in lines.pop()
1502 assert "Related" in lines.pop()
1503 assert "jump>1" in lines.pop()
1504 assert "jump>2" in lines.pop()
1505 assert "group-alternative" in lines.pop()
1506 assert "Alternative" in lines.pop()
1507 assert "send-message" in lines.pop()
1508 assert len(lines) == 0
1510 @pytest.mark.massage
1511 def test_mime_tree_nested_trees_does_not_break_positioning(
1512 self, mime_tree_nested
1514 def converter(draft_f, **kwargs):
1515 return mime_tree_nested
1517 with File() as draft_f, File() as cmd_f:
1521 converter=converter,
1523 lines = cmd_f.read().splitlines()
1525 while "logo.png" not in lines.pop():
1528 assert "content-id" in lines.pop()
1529 assert "move-up" in lines.pop()
1532 if "move-up" not in top:
1534 assert "move-down" in top
1535 # Due to the nested trees, the number of descendents of the sibling
1536 # actually needs to be considered, not just the nieces. So to move
1537 # from position 1 to position 6, it only needs one <move-down>
1538 # because that jumps over the entire sibling tree. Thus what
1539 # follows next must not be another <move-down>
1540 assert "Logo" in lines.pop()
1542 @pytest.mark.converter
1543 def test_converter_tree_basic(self, fakepath, const1, fakefilefactory):
1544 draft_f = fakefilefactory(fakepath, content=const1)
1545 tree = convert_markdown_to_html(
1546 draft_f, filefactory=fakefilefactory
1549 assert tree.subtype == "alternative"
1550 assert len(tree.children) == 2
1551 assert tree.children[0].subtype == "plain"
1552 assert tree.children[0].path == draft_f.path
1553 assert tree.children[0].orig
1554 assert tree.children[1].subtype == "html"
1555 assert tree.children[1].path == fakepath.with_suffix(".html")
1557 @pytest.mark.converter
1558 def test_converter_writes(
1559 self, fakepath, fakefilefactory, const1, monkeypatch
1561 draft_f = fakefilefactory(fakepath, content=const1)
1562 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1564 html = fakefilefactory.pop()
1565 assert fakepath.with_suffix(".html") == html[0]
1566 assert const1 in html[1].read()
1567 text = fakefilefactory.pop()
1568 assert fakepath == text[0]
1569 assert const1 == text[1].read()
1571 @pytest.mark.imgproc
1572 def test_markdown_inline_image_processor(self):
1573 imgpath1 = "file:/path/to/image.png"
1574 imgpath2 = "file:///path/to/image.png?url=params"
1575 imgpath3 = "/path/to/image.png"
1576 text = f"""![inline local image]({imgpath1})
1578 with newline]({imgpath2})
1579 ![image local path]({imgpath3})"""
1580 text, html, images, mdwn = markdown_with_inline_image_support(text)
1582 # local paths have been normalised to URLs:
1583 imgpath3 = f"file://{imgpath3}"
1585 assert 'src="cid:' in html
1586 assert "](cid:" in text
1587 assert len(images) == 3
1588 assert imgpath1 in images
1589 assert imgpath2 in images
1590 assert imgpath3 in images
1591 assert images[imgpath1].cid != images[imgpath2].cid
1592 assert images[imgpath1].cid != images[imgpath3].cid
1593 assert images[imgpath2].cid != images[imgpath3].cid
1595 @pytest.mark.imgproc
1596 def test_markdown_inline_image_processor_title_to_desc(self, const1):
1597 imgpath = "file:///path/to/image.png"
1598 text = f'![inline local image]({imgpath} "{const1}")'
1599 text, html, images, mdwn = markdown_with_inline_image_support(text)
1600 assert images[imgpath].desc == const1
1602 @pytest.mark.imgproc
1603 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
1604 imgpath = "file:///path/to/image.png"
1605 text = f"![{const1}]({imgpath})"
1606 text, html, images, mdwn = markdown_with_inline_image_support(text)
1607 assert images[imgpath].desc == const1
1609 @pytest.mark.imgproc
1610 def test_markdown_inline_image_processor_title_over_alt_desc(
1611 self, const1, const2
1613 imgpath = "file:///path/to/image.png"
1614 text = f'![{const1}]({imgpath} "{const2}")'
1615 text, html, images, mdwn = markdown_with_inline_image_support(text)
1616 assert images[imgpath].desc == const2
1618 @pytest.mark.imgproc
1619 def test_markdown_inline_image_not_external(self):
1620 imgpath = "https://path/to/image.png"
1621 text = f"![inline image]({imgpath})"
1622 text, html, images, mdwn = markdown_with_inline_image_support(text)
1624 assert 'src="cid:' not in html
1625 assert "](cid:" not in text
1626 assert len(images) == 0
1628 @pytest.mark.imgproc
1629 def test_markdown_inline_image_local_file(self):
1630 imgpath = "/path/to/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}"
1638 @pytest.mark.imgproc
1639 def test_markdown_inline_image_expanduser(self):
1640 imgpath = pathlib.Path("~/image.png")
1641 text = f"![inline image]({imgpath})"
1642 text, html, images, mdwn = markdown_with_inline_image_support(text)
1644 for k, v in images.items():
1645 assert k == f"file://{imgpath.expanduser()}"
1651 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
1652 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
1655 @pytest.mark.imgproc
1656 def test_markdown_inline_image_processor_base64(self, test_png):
1657 text = f"![1px white inlined]({test_png})"
1658 text, html, images, mdwn = markdown_with_inline_image_support(text)
1660 assert 'src="cid:' in html
1661 assert "](cid:" in text
1662 assert len(images) == 1
1663 assert test_png in images
1665 @pytest.mark.converter
1666 def test_converter_tree_inline_image_base64(
1667 self, test_png, fakefilefactory
1669 text = f"![inline base64 image]({test_png})"
1670 with fakefilefactory(content=text) as draft_f:
1671 tree = convert_markdown_to_html(
1673 filefactory=fakefilefactory,
1674 related_to_html_only=False,
1676 assert tree.subtype == "relative"
1677 assert tree.children[0].subtype == "alternative"
1678 assert tree.children[1].subtype == "png"
1679 written = fakefilefactory.pop()
1680 assert tree.children[1].path == written[0]
1681 assert b"PNG" in written[1].read()
1683 @pytest.mark.converter
1684 def test_converter_tree_inline_image_base64_related_to_html(
1685 self, test_png, fakefilefactory
1687 text = f"![inline base64 image]({test_png})"
1688 with fakefilefactory(content=text) as draft_f:
1689 tree = convert_markdown_to_html(
1691 filefactory=fakefilefactory,
1692 related_to_html_only=True,
1694 assert tree.subtype == "alternative"
1695 assert tree.children[1].subtype == "relative"
1696 assert tree.children[1].children[1].subtype == "png"
1697 written = fakefilefactory.pop()
1698 assert tree.children[1].children[1].path == written[0]
1699 assert b"PNG" in written[1].read()
1701 @pytest.mark.converter
1702 def test_converter_tree_inline_image_cid(
1703 self, const1, fakefilefactory
1705 text = f"![inline base64 image](cid:{const1})"
1706 with fakefilefactory(content=text) as draft_f:
1707 tree = convert_markdown_to_html(
1709 filefactory=fakefilefactory,
1710 related_to_html_only=False,
1712 assert len(tree.children) == 2
1713 assert tree.children[0].cid != const1
1714 assert tree.children[0].type != "image"
1715 assert tree.children[1].cid != const1
1716 assert tree.children[1].type != "image"
1719 def fakefilefactory(self):
1720 return FakeFileFactory()
1722 @pytest.mark.imgcoll
1723 def test_inline_image_collection(
1724 self, test_png, const1, const2, fakefilefactory
1726 test_images = {test_png: InlineImageInfo(cid=const1, desc=const2)}
1727 relparts = collect_inline_images(
1728 test_images, filefactory=fakefilefactory
1731 written = fakefilefactory.pop()
1732 assert b"PNG" in written[1].read()
1734 assert relparts[0].subtype == "png"
1735 assert relparts[0].path == written[0]
1736 assert relparts[0].cid == const1
1737 assert const2 in relparts[0].desc
1741 @pytest.mark.styling
1742 def test_apply_stylesheet(self):
1743 html = "<p>Hello, world!</p>"
1744 css = "p { color:red }"
1745 out = apply_styling(html, css)
1746 assert 'p style="color' in out
1748 @pytest.mark.styling
1749 def test_apply_no_stylesheet(self, const1):
1750 out = apply_styling(const1, None)
1752 @pytest.mark.massage
1753 @pytest.mark.styling
1754 def test_massage_styling_to_converter(self):
1755 css = "p { color:red }"
1756 css_f = File(content=css)
1759 def converter(draft_f, css_f, **kwargs):
1761 css_applied.append(css)
1762 return Part("text", "plain", draft_f.path, orig=True)
1768 converter=converter,
1770 assert css_applied[0] == css
1772 @pytest.mark.converter
1773 @pytest.mark.styling
1774 def test_converter_apply_styles(
1775 self, const1, monkeypatch, fakepath, fakefilefactory
1777 css = "p { color:red }"
1779 monkeypatch.context() as m,
1780 fakefilefactory(fakepath, content=const1) as draft_f,
1781 fakefilefactory(content=css) as css_f,
1786 lambda s, t: f"<p>{t}</p>",
1788 convert_markdown_to_html(
1789 draft_f, css_f=css_f, filefactory=fakefilefactory
1793 fakefilefactory[fakepath.with_suffix(".html")].read(),
1798 @pytest.mark.styling
1799 def test_apply_stylesheet_pygments(self):
1801 f'<div class="{_CODEHILITE_CLASS}">'
1802 "<pre>def foo():\n return</pre></div>"
1804 out = apply_styling(html, _PYGMENTS_CSS)
1805 assert f'{_CODEHILITE_CLASS}" style="' in out
1808 def test_signature_extraction_no_signature(self, const1):
1809 assert (const1, None, None) == extract_signature(const1)
1812 def test_signature_extraction_just_text(self, const1, const2):
1813 origtext, textsig, htmlsig = extract_signature(
1814 f"{const1}{EMAIL_SIG_SEP}{const2}"
1816 assert origtext == const1
1817 assert textsig == const2
1818 assert htmlsig is None
1821 def test_signature_extraction_html(
1822 self, fakepath, fakefilefactory, const1, const2
1824 sigconst = "HTML signature from {path} but as a string"
1825 sig = f'<div id="signature">{sigconst.format(path=fakepath)}</div>'
1827 sig_f = fakefilefactory(fakepath, content=sig)
1829 origtext, textsig, htmlsig = extract_signature(
1830 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER} {fakepath}\n{const2}",
1831 filefactory=fakefilefactory,
1833 assert origtext == const1
1834 assert textsig == const2
1835 assert htmlsig == sigconst.format(path=fakepath)
1838 def test_signature_extraction_file_not_found(self, const1):
1839 path = pathlib.Path("/does/not/exist")
1840 with pytest.raises(FileNotFoundError):
1841 origtext, textsig, htmlsig = extract_signature(
1842 f"{const1}{EMAIL_SIG_SEP}{HTML_SIG_MARKER}{path}\n{const1}"
1845 @pytest.mark.imgproc
1846 def test_image_registry(self, const1):
1847 reg = ImageRegistry()
1848 cid = reg.register(const1)
1850 assert not cid.startswith("<")
1851 assert not cid.endswith(">")
1852 assert const1 in reg
1854 @pytest.mark.imgproc
1855 def test_image_registry_file_uri(self, const1):
1856 reg = ImageRegistry()
1857 reg.register("/some/path")
1859 assert path.startswith("file://")
1862 @pytest.mark.converter
1864 def test_converter_signature_handling(
1865 self, fakepath, fakefilefactory, monkeypatch
1868 "This is the mail body\n",
1870 "This is a plain-text signature only",
1875 fakepath, content="".join(mailparts)
1877 monkeypatch.context() as m,
1879 m.setattr(markdown.Markdown, "convert", lambda s, t: t)
1880 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1882 soup = bs4.BeautifulSoup(
1883 fakefilefactory[fakepath.with_suffix(".html")].read(),
1886 body = soup.body.contents
1888 assert mailparts[0] in body.pop(0)
1890 sig = soup.select_one("#signature")
1891 assert sig == body.pop(0)
1893 sep = sig.select_one("span.sig_separator")
1894 assert sep == sig.contents[0]
1895 assert f"\n{sep.text}\n" == EMAIL_SIG_SEP
1897 assert mailparts[2] in sig.contents[1]
1899 @pytest.mark.converter
1901 def test_converter_signature_handling_htmlsig(
1902 self, fakepath, fakepath2, fakefilefactory, monkeypatch
1905 "This is the mail body",
1907 f"{HTML_SIG_MARKER}{fakepath2}\n",
1908 "This is the plain-text version",
1910 htmlsig = "HTML Signature from {path} but as a string"
1912 f'<div id="signature"><p>{htmlsig.format(path=fakepath2)}</p></div>'
1915 sig_f = fakefilefactory(fakepath2, content=html)
1922 fakepath, content="".join(mailparts)
1924 monkeypatch.context() as m,
1927 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1929 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1931 soup = bs4.BeautifulSoup(
1932 fakefilefactory[fakepath.with_suffix(".html")].read(),
1935 sig = soup.select_one("#signature")
1938 assert HTML_SIG_MARKER not in sig.text
1939 assert htmlsig.format(path=fakepath2) == sig.text.strip()
1941 plaintext = fakefilefactory[fakepath].read()
1942 assert plaintext.endswith(EMAIL_SIG_SEP + mailparts[-1])
1944 @pytest.mark.converter
1946 def test_converter_signature_handling_htmlsig_with_image(
1947 self, fakepath, fakepath2, fakefilefactory, monkeypatch, test_png
1950 "This is the mail body",
1952 f"{HTML_SIG_MARKER}{fakepath2}\n",
1953 "This is the plain-text version",
1956 "HTML Signature from {path} with image\n"
1957 f'<img src="{test_png}">\n'
1960 f'<div id="signature">{htmlsig.format(path=fakepath2)}</div>'
1963 sig_f = fakefilefactory(fakepath2, content=html)
1970 fakepath, content="".join(mailparts)
1972 monkeypatch.context() as m,
1975 markdown.Markdown, "convert", lambda s, t: mdwn_fn(t)
1977 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
1979 assert fakefilefactory.pop()[0].suffix == ".png"
1981 soup = bs4.BeautifulSoup(
1982 fakefilefactory[fakepath.with_suffix(".html")].read(),
1985 assert soup.img.attrs["src"].startswith("cid:")
1987 @pytest.mark.converter
1989 def test_converter_signature_handling_textsig_with_image(
1990 self, fakepath, fakefilefactory, test_png
1993 "This is the mail body",
1995 "This is the plain-text version with image\n",
1996 f"![Inline]({test_png})",
2000 fakepath, content="".join(mailparts)
2003 tree = convert_markdown_to_html(
2004 draft_f, filefactory=fakefilefactory
2007 assert tree.subtype == "relative"
2008 assert tree.children[0].subtype == "alternative"
2009 assert tree.children[1].subtype == "png"
2010 written = fakefilefactory.pop()
2011 assert tree.children[1].path == written[0]
2012 assert written[1].read() == request.urlopen(test_png).read()
2014 @pytest.mark.converter
2015 def test_converter_attribution_to_admonition(
2016 self, fakepath, fakefilefactory
2019 "Regarding whatever",
2020 "> blockquote line1",
2021 "> blockquote line2",
2023 "> new para with **bold** text",
2025 with fakefilefactory(
2026 fakepath, content="\n".join(mailparts)
2028 convert_markdown_to_html(draft_f, filefactory=fakefilefactory)
2030 soup = bs4.BeautifulSoup(
2031 fakefilefactory[fakepath.with_suffix(".html")].read(),
2034 quote = soup.select_one("div.admonition.quote")
2037 soup.select_one("p.admonition-title").extract().text.strip()
2041 p = quote.p.extract()
2042 assert p.text.strip() == "\n".join(p[2:] for p in mailparts[1:3])
2044 p = quote.p.extract()
2045 assert p.contents[1].name == "strong"
2047 @pytest.mark.converter
2048 def test_converter_attribution_to_admonition_multiple(
2049 self, fakepath, fakefilefactory
2052 "Regarding whatever",
2053 "> blockquote line1",
2054 "> blockquote line2",
2058 "> continued emailquote",
2060 "Another email-quote",
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.continued").extract()
2075 quote.select_one("p.admonition-title").extract().text.strip()
2079 p = quote.p.extract()
2082 quote = soup.select_one("div.admonition.quote.continued").extract()
2085 quote.select_one("p.admonition-title").extract().text.strip()
2090 def test_file_class_contextmanager(self, const1, monkeypatch):
2091 state = dict(o=False, c=False)
2096 with monkeypatch.context() as m:
2097 m.setattr(File, "open", lambda s: fn("o"))
2098 m.setattr(File, "close", lambda s: fn("c"))
2101 assert not state["c"]
2105 def test_file_class_no_path(self, const1):
2106 with File(mode="w+") as f:
2107 f.write(const1, cache=False)
2108 assert f.read(cache=False) == const1
2111 def test_file_class_path(self, const1, tmp_path):
2112 with File(tmp_path / "file", mode="w+") as f:
2113 f.write(const1, cache=False)
2114 assert f.read(cache=False) == const1
2117 def test_file_class_cache(self, tmp_path, const1, const2):
2118 path = tmp_path / "file"
2119 file = File(path, mode="w+")
2121 f.write(const1, cache=True)
2122 with open(path, mode="w") as f:
2125 assert f.read(cache=True) == const1
2128 def test_file_class_cache_init(self, const1):
2129 file = File(path=None, mode="r", content=const1)
2131 assert f.read() == const1
2134 def test_file_class_content_or_path(self, fakepath, const1):
2135 with pytest.raises(RuntimeError):
2136 file = File(path=fakepath, content=const1)
2139 def test_file_class_content_needs_read(self, const1):
2140 with pytest.raises(RuntimeError):
2141 file = File(mode="w", content=const1)
2144 def test_file_class_write_persists_close(self, const1):
2149 assert f.read() == const1
2152 def test_file_class_write_resets_read_cache(self, const1, const2):
2153 with File(mode="w+", content=const1) as f:
2154 assert f.read() == const1
2156 assert f.read() == const2
2159 def test_file_factory(self):
2160 fact = FileFactory()
2162 assert isinstance(f, File)
2163 assert len(fact) == 1
2168 def test_fake_file_factory(self, fakepath, fakefilefactory):
2169 fact = FakeFileFactory()
2170 f = fakefilefactory(fakepath)
2171 assert f.path == fakepath
2172 assert f == fakefilefactory[fakepath]
2175 def test_fake_file_factory_path_persistence(
2176 self, fakepath, fakefilefactory
2178 f1 = fakefilefactory(fakepath)
2179 assert f1 == fakefilefactory(fakepath)