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):
10 # <enter-command> source '$my_confdir/buildmimetree.py setup --tempdir $tempdir|'<enter>\
11 # <enter-command> sourc e \$my_mdwn_postprocess_cmd_file<enter>\
12 # " "Convert message into a modern MIME tree with inline images"
14 # (Yes, we need to call source twice, as mutt only starts to process output
15 # from a source command when the command exits, and since we need to react
16 # to the output, we need to be invoked again, using a $my_ variable to pass
25 # - Pygments, if installed, then syntax highlighting is enabled
28 # https://git.madduck.net/etc/neomutt.git/blob_plain/HEAD:/.config/neomutt/buildmimetree.py
30 # Copyright © 2023 martin f. krafft <madduck@madduck.net>
31 # Released under the GPL-2+ licence, just like Mutt itself.
41 from collections import namedtuple, OrderedDict
42 from markdown.extensions import Extension
43 from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
44 from email.utils import make_msgid
45 from urllib import request
48 def parse_cli_args(*args, **kwargs):
49 parser = argparse.ArgumentParser(
51 "NeoMutt helper to turn text/markdown email parts "
52 "into full-fledged MIME trees"
56 "Copyright © 2022 martin f. krafft <madduck@madduck.net>.\n"
57 "Released under the MIT licence"
60 subp = parser.add_subparsers(help="Sub-command parsers", dest="mode")
61 parser_setup = subp.add_parser("setup", help="Setup phase")
62 parser_massage = subp.add_parser("massage", help="Massaging phase")
64 parser_setup.add_argument(
67 help="Turn on debug logging of commands generated to stderr",
70 parser_setup.add_argument(
73 help="Specify temporary directory to use for attachments",
76 parser_setup.add_argument(
84 help="Markdown extension to add to the list of extensions use",
87 parser_setup.add_argument(
90 help="Generate command(s) to send the message after processing",
93 parser_massage.add_argument(
96 help="Turn on debug logging of commands generated to stderr",
99 parser_massage.add_argument(
102 help="Turn on debugging to stderr of the MIME tree walk",
105 parser_massage.add_argument(
108 help="Specify temporary directory to use for attachments",
111 parser_massage.add_argument(
113 metavar="EXTENSIONS",
116 help="Markdown extension to use (comma-separated list)",
119 parser_massage.add_argument(
120 "--write-commands-to",
123 help="Temporary file path to write commands to",
126 parser_massage.add_argument(
129 help="If provided, the script is invoked as editor on the mail draft",
132 return parser.parse_args(*args, **kwargs)
135 # [ MARKDOWN WRAPPING ] #######################################################
138 InlineImageInfo = namedtuple(
139 "InlineImageInfo", ["cid", "desc"], defaults=[None]
143 class InlineImageExtension(Extension):
144 class RelatedImageInlineProcessor(ImageInlineProcessor):
145 def __init__(self, re, md, ext):
146 super().__init__(re, md)
149 def handleMatch(self, m, data):
150 el, start, end = super().handleMatch(m, data)
151 if "src" in el.attrib:
152 src = el.attrib["src"]
153 if "://" not in src or src.startswith("file://"):
154 # We only inline local content
155 cid = self._ext.get_cid_for_image(el.attrib)
156 el.attrib["src"] = f"cid:{cid}"
157 return el, start, end
161 self._images = OrderedDict()
163 def extendMarkdown(self, md):
164 md.registerExtension(self)
165 inline_image_proc = self.RelatedImageInlineProcessor(
166 IMAGE_LINK_RE, md, self
168 md.inlinePatterns.register(inline_image_proc, "image_link", 150)
170 def get_cid_for_image(self, attrib):
171 msgid = make_msgid()[1:-1]
173 if path.startswith("/"):
174 path = f"file://{path}"
175 self._images[path] = InlineImageInfo(
176 msgid, attrib.get("title", attrib.get("alt"))
180 def get_images(self):
184 def markdown_with_inline_image_support(text, *, extensions=None):
185 inline_image_handler = InlineImageExtension()
186 extensions = extensions or []
187 extensions.append(inline_image_handler)
188 mdwn = markdown.Markdown(extensions=extensions)
189 htmltext = mdwn.convert(text)
191 images = inline_image_handler.get_images()
193 def replace_image_with_cid(matchobj):
194 for m in (matchobj.group(1), f"file://{matchobj.group(1)}"):
196 return f"(cid:{images[m].cid}"
197 return matchobj.group(0)
199 text = re.sub(r"\(([^)\s]+)", replace_image_with_cid, text)
200 return text, htmltext, images
203 # [ PARTS GENERATION ] ########################################################
209 ["type", "subtype", "path", "desc", "cid", "orig"],
210 defaults=[None, None, False],
214 ret = f"<{self.type}/{self.subtype}>"
216 ret = f"{ret} cid:{self.cid}"
218 ret = f"{ret} ORIGINAL"
223 namedtuple("Multipart", ["subtype", "children", "desc"], defaults=[None])
226 return f"<multipart/{self.subtype}> children={len(self.children)}"
229 def filewriter_fn(path, content, mode="w", **kwargs):
230 with open(path, mode, **kwargs) as out_f:
234 def collect_inline_images(
235 images, *, tempdir=None, filewriter_fn=filewriter_fn
238 for path, info in images.items():
239 data = request.urlopen(path)
241 mimetype = data.headers["Content-Type"]
242 ext = mimetypes.guess_extension(mimetype)
243 tempfilename = tempfile.mkstemp(prefix="img", suffix=ext, dir=tempdir)
244 path = pathlib.Path(tempfilename[1])
246 filewriter_fn(path, data.read(), "w+b")
249 Part(*mimetype.split("/"), path, cid=info.cid, desc=info.desc)
255 def convert_markdown_to_html(
259 filewriter_fn=filewriter_fn,
263 origtext, htmltext, images = markdown_with_inline_image_support(
264 origtext, extensions=extensions
267 filewriter_fn(draftpath, origtext, encoding="utf-8")
269 "text", "plain", draftpath, "Plain-text version", orig=True
272 htmlpath = draftpath.with_suffix(".html")
274 htmlpath, htmltext, encoding="utf-8", errors="xmlcharrefreplace"
276 htmlpart = Part("text", "html", htmlpath, "HTML version")
279 "alternative", [textpart, htmlpart], "Group of alternative content"
282 imgparts = collect_inline_images(
283 images, tempdir=tempdir, filewriter_fn=filewriter_fn
287 "relative", [altpart] + imgparts, "Group of related content"
293 class MIMETreeDFWalker:
294 def __init__(self, *, visitor_fn=None, debug=False):
295 self._visitor_fn = visitor_fn
298 def walk(self, root, *, visitor_fn=None):
300 Recursive function to implement a depth-dirst walk of the MIME-tree
304 if isinstance(root, list):
305 root = Multipart("mixed", children=root)
310 visitor_fn=visitor_fn or self._visitor_fn,
313 def _walk(self, node, *, stack, visitor_fn):
314 # Let's start by enumerating the parts at the current level. At the
315 # root level, stack will be the empty list, and we expect a multipart/*
316 # container at this level. Later, e.g. within a mutlipart/alternative
317 # container, the subtree will just be the alternative parts, while the
318 # top of the stack will be the multipart/alternative container, which
319 # we will process after the following loop.
321 lead = f"{'| '*len(stack)}|-"
322 if isinstance(node, Multipart):
324 f"{lead}{node} parents={[s.subtype for s in stack]}"
327 # Depth-first, so push the current container onto the stack,
330 self.debugprint("| " * (len(stack) + 1))
331 for child in node.children:
335 visitor_fn=visitor_fn,
337 self.debugprint("| " * len(stack))
338 assert stack.pop() == node
341 self.debugprint(f"{lead}{node}")
344 visitor_fn(node, stack, debugprint=self.debugprint)
346 def debugprint(self, s, **kwargs):
348 print(s, file=sys.stderr, **kwargs)
351 # [ RUN MODES ] ###############################################################
356 Stupid class to interface writing out Mutt commands. This is quite a hack
357 to deal with the fact that Mutt runs "push" commands in reverse order, so
358 all of a sudden, things become very complicated when mixing with "real"
361 Hence we keep two sets of commands, and one set of pushes. Commands are
362 added to the first until a push is added, after which commands are added to
363 the second set of commands.
365 On flush(), the first set is printed, followed by the pushes in reverse,
366 and then the second set is printed. All 3 sets are then cleared.
369 def __init__(self, out_f=sys.stdout, *, debug=False):
370 self._cmd1, self._push, self._cmd2 = [], [], []
382 s = s.replace('"', '"')
385 self._push.insert(0, s)
389 "\n".join(self._cmd1 + self._push + self._cmd2), file=self._out_f
391 self._cmd1, self._push, self._cmd2 = [], [], []
393 def debugprint(self, s, **kwargs):
395 print(s, file=sys.stderr, **kwargs)
404 debug_commands=False,
406 extensions = extensions or []
407 temppath = temppath or pathlib.Path(
408 tempfile.mkstemp(prefix="muttmdwn-", dir=tempdir)[1]
410 cmds = MuttCommands(out_f, debug=debug_commands)
412 editor = f"{sys.argv[0]} massage --write-commands-to {temppath}"
414 editor = f'{editor} --extensions {",".join(extensions)}'
416 editor = f"{editor} --tempdir {tempdir}"
418 editor = f"{editor} --debug-commands"
420 cmds.cmd('set my_editor="$editor"')
421 cmds.cmd('set my_edit_headers="$edit_headers"')
422 cmds.cmd(f'set editor="{editor}"')
423 cmds.cmd("unset edit_headers")
424 cmds.cmd(f"set my_mdwn_postprocess_cmd_file={temppath}")
425 cmds.push("<first-entry><edit-file>")
435 converter=convert_markdown_to_html,
437 debug_commands=False,
440 # Here's the big picture: we're being invoked as the editor on the email
441 # draft, and whatever commands we write to the file given as cmdpath will
442 # be run by the second source command in the macro definition.
444 # Let's start by cleaning up what the setup did (see above), i.e. we
445 # restore the $editor and $edit_headers variables, and also unset the
446 # variable used to identify the command file we're currently writing
448 cmds = MuttCommands(cmd_f, debug=debug_commands)
449 cmds.cmd('set editor="$my_editor"')
450 cmds.cmd('set edit_headers="$my_edit_headers"')
451 cmds.cmd("unset my_editor")
452 cmds.cmd("unset my_edit_headers")
454 # let's flush those commands, as there'll be a lot of pushes from now
455 # on, which need to be run in reverse order
458 extensions = extensions.split(",") if extensions else []
459 tree = converter(draft_f.read(), draftpath, tempdir=tempdir, extensions=extensions)
461 mimetree = MIMETreeDFWalker(debug=debug_walk)
463 def visitor_fn(item, stack, *, debugprint=None):
465 Visitor function called for every node (part) of the MIME tree,
466 depth-first, and responsible for telling NeoMutt how to assemble
469 if isinstance(item, Part):
470 # We've hit a leaf-node, i.e. an alternative or a related part
471 # with actual content.
475 # The original source already exists in the NeoMutt tree, but
476 # the underlying file may have been modified, so we need to
477 # update the encoding, but that's it:
478 cmds.push("<update-encoding>")
480 # … whereas all other parts need to be added, and they're all
481 # considered to be temporary and inline:
482 cmds.push(f"<attach-file>{item.path}<enter>")
483 cmds.push("<toggle-unlink><toggle-disposition>")
485 # If the item (including the original) comes with additional
486 # information, then we might just as well update the NeoMutt
489 cmds.push(f"<edit-content-id>\\Ca\\Ck{item.cid}<enter>")
491 elif isinstance(item, Multipart):
492 # This node has children, but we already visited them (see
493 # above), and so they have been tagged in NeoMutt's compose
494 # window. Now it's just a matter of telling NeoMutt to do the
495 # appropriate grouping:
496 if item.subtype == "alternative":
497 cmds.push("<group-alternatives>")
498 elif item.subtype == "relative":
499 cmds.push("<group-related>")
500 elif item.subtype == "multilingual":
501 cmds.push("<group-multilingual>")
504 # We should never get here
505 assert not "is valid part"
507 # If the item has a description, we might just as well add it
509 cmds.push(f"<edit-description>\\Ca\\Ck{item.desc}<enter>")
511 # Finally, if we're at non-root level, tag the new container,
512 # as it might itself be part of a container, to be processed
515 cmds.push("<tag-entry>")
520 # Let's walk the tree and visit every node with our fancy visitor
522 mimetree.walk(tree, visitor_fn=visitor_fn)
524 # Finally, cleanup. Since we're responsible for removing the temporary
525 # file, how's this for a little hack?
527 filename = cmd_f.name
528 except AttributeError:
529 filename = "pytest_internal_file"
530 cmds.cmd(f"source 'rm -f {filename}|'")
531 cmds.cmd("unset my_mdwn_postprocess_cmd_file")
535 # [ CLI ENTRY ] ###############################################################
537 if __name__ == "__main__":
538 args = parse_cli_args()
540 if args.mode == "setup":
541 if args.send_message:
542 raise NotImplementedError()
546 tempdir=args.tempdir,
547 debug_commands=args.debug_commands,
550 elif args.mode == "massage":
551 with open(args.MAILDRAFT, "r") as draft_f, open(
556 pathlib.Path(args.MAILDRAFT),
558 extensions=args.extensions,
559 tempdir=args.tempdir,
560 debug_commands=args.debug_commands,
561 debug_walk=args.debug_walk,
565 # [ TESTS ] ###################################################################
569 from io import StringIO
574 return "CONSTANT STRING 1"
578 return "CONSTANT STRING 2"
580 # NOTE: tests using the capsys fixture must specify sys.stdout to the
581 # functions they call, else old stdout is used and not captured
583 def test_MuttCommands_cmd(self, const1, const2, capsys):
584 "Assert order of commands"
585 cmds = MuttCommands(out_f=sys.stdout)
589 captured = capsys.readouterr()
590 assert captured.out == "\n".join((const1, const2, ""))
592 def test_MuttCommands_push(self, const1, const2, capsys):
593 "Assert reverse order of pushes"
594 cmds = MuttCommands(out_f=sys.stdout)
598 captured = capsys.readouterr()
601 == ('"\npush "'.join(("", const2, const1, "")))[2:-6]
604 def test_MuttCommands_cmd_push_mixed(self, const1, const2, capsys):
605 "Assert reverse order of pushes"
606 cmds = MuttCommands(out_f=sys.stdout)
607 lines = ["000", "001", "010", "011", "100", "101", "110", "111"]
609 cmds.cmd(lines[4 * i + 0])
610 cmds.cmd(lines[4 * i + 1])
611 cmds.push(lines[4 * i + 2])
612 cmds.push(lines[4 * i + 3])
615 captured = capsys.readouterr()
616 lines_out = captured.out.splitlines()
617 assert lines[0] in lines_out[0]
618 assert lines[1] in lines_out[1]
619 assert lines[7] in lines_out[2]
620 assert lines[6] in lines_out[3]
621 assert lines[3] in lines_out[4]
622 assert lines[2] in lines_out[5]
623 assert lines[4] in lines_out[6]
624 assert lines[5] in lines_out[7]
627 def basic_mime_tree(self):
641 Part("text", "html", "part.html", desc="HTML"),
646 "text", "png", "logo.png", cid="logo.png", desc="Logo"
652 def test_MIMETreeDFWalker_depth_first_walk(self, basic_mime_tree):
653 mimetree = MIMETreeDFWalker()
657 def visitor_fn(item, stack, debugprint):
658 items.append((item, len(stack)))
660 mimetree.walk(basic_mime_tree, visitor_fn=visitor_fn)
661 assert len(items) == 5
662 assert items[0][0].subtype == "plain"
663 assert items[0][1] == 2
664 assert items[1][0].subtype == "html"
665 assert items[1][1] == 2
666 assert items[2][0].subtype == "alternative"
667 assert items[2][1] == 1
668 assert items[3][0].subtype == "png"
669 assert items[3][1] == 1
670 assert items[4][0].subtype == "relative"
671 assert items[4][1] == 0
673 def test_MIMETreeDFWalker_list_to_mixed(self, basic_mime_tree):
674 mimetree = MIMETreeDFWalker()
677 def visitor_fn(item, stack, debugprint):
680 mimetree.walk([basic_mime_tree], visitor_fn=visitor_fn)
681 assert items[-1].subtype == "mixed"
683 def test_MIMETreeDFWalker_visitor_in_constructor(
684 self, basic_mime_tree
688 def visitor_fn(item, stack, debugprint):
691 mimetree = MIMETreeDFWalker(visitor_fn=visitor_fn)
692 mimetree.walk(basic_mime_tree)
693 assert len(items) == 5
695 def test_do_setup_no_extensions(self, const1, capsys):
696 "Assert basics about the setup command output"
697 do_setup(temppath=const1, out_f=sys.stdout)
698 captout = capsys.readouterr()
699 lines = captout.out.splitlines()
700 assert lines[2].endswith(f'{const1}"')
701 assert lines[4].endswith(const1)
702 assert "first-entry" in lines[-1]
703 assert "edit-file" in lines[-1]
705 def test_do_setup_extensions(self, const1, const2, capsys):
706 "Assert that extensions are passed to editor"
708 temppath=const1, extensions=[const2, const1], out_f=sys.stdout
710 captout = capsys.readouterr()
711 lines = captout.out.splitlines()
712 # assert comma-separated list of extensions passed
713 assert lines[2].endswith(f'{const2},{const1}"')
714 assert lines[4].endswith(const1)
717 def string_io(self, const1, text=None):
718 return StringIO(text or const1)
720 def test_do_massage_basic(self, const1, string_io, capsys):
721 def converter(drafttext, draftpath, extensions, tempdir):
722 return Part("text", "plain", draftpath, orig=True)
731 captured = capsys.readouterr()
732 lines = captured.out.splitlines()
733 assert '="$my_editor"' in lines.pop(0)
734 assert '="$my_edit_headers"' in lines.pop(0)
735 assert "unset my_editor" == lines.pop(0)
736 assert "unset my_edit_headers" == lines.pop(0)
737 assert "update-encoding" in lines.pop(0)
738 assert "source 'rm -f " in lines.pop(0)
739 assert "unset my_mdwn_postprocess_cmd_file" == lines.pop(0)
741 def test_do_massage_fulltree(
742 self, string_io, const1, basic_mime_tree, capsys
744 def converter(drafttext, draftpath, extensions, tempdir):
745 return basic_mime_tree
754 captured = capsys.readouterr()
755 lines = captured.out.splitlines()[4:]
756 assert "Related" in lines.pop(0)
757 assert "group-related" in lines.pop(0)
758 assert "tag-entry" in lines.pop(0)
759 assert "Logo" in lines.pop(0)
760 assert "content-id" in lines.pop(0)
761 assert "toggle-unlink" in lines.pop(0)
762 assert "logo.png" in lines.pop(0)
763 assert "tag-entry" in lines.pop(0)
764 assert "Alternative" in lines.pop(0)
765 assert "group-alternatives" in lines.pop(0)
766 assert "tag-entry" in lines.pop(0)
767 assert "HTML" in lines.pop(0)
768 assert "toggle-unlink" in lines.pop(0)
769 assert "part.html" in lines.pop(0)
770 assert "tag-entry" in lines.pop(0)
771 assert "Plain" in lines.pop(0)
772 assert "update-encoding" in lines.pop(0)
773 assert len(lines) == 2
776 def fake_filewriter(self):
781 def __call__(self, path, content, mode="w", **kwargs):
782 self._writes.append((path, content))
784 def pop(self, index=-1):
785 return self._writes.pop(index)
790 def markdown_non_converter(self, const1, const2):
791 return lambda s, text: f"{const1}{text}{const2}"
793 def test_converter_tree_basic(
794 self, const1, const2, fake_filewriter, markdown_non_converter
796 path = pathlib.Path(const2)
797 tree = convert_markdown_to_html(
798 const1, path, filewriter_fn=fake_filewriter
801 assert tree.subtype == "alternative"
802 assert len(tree.children) == 2
803 assert tree.children[0].subtype == "plain"
804 assert tree.children[0].path == path
805 assert tree.children[0].orig
806 assert tree.children[1].subtype == "html"
807 assert tree.children[1].path == path.with_suffix(".html")
809 def test_converter_writes(
815 markdown_non_converter,
817 path = pathlib.Path(const2)
819 with monkeypatch.context() as m:
820 m.setattr(markdown.Markdown, "convert", markdown_non_converter)
821 convert_markdown_to_html(
822 const1, path, filewriter_fn=fake_filewriter
825 assert (path, const1) == fake_filewriter.pop(0)
827 path.with_suffix(".html"),
828 markdown_non_converter(None, const1),
829 ) == fake_filewriter.pop(0)
831 def test_markdown_inline_image_processor(self):
832 imgpath1 = "file:/path/to/image.png"
833 imgpath2 = "file:///path/to/image.png?url=params"
834 imgpath3 = "/path/to/image.png"
835 text = f"""![inline local image]({imgpath1})
837 with newline]({imgpath2})
838 ![image local path]({imgpath3})"""
839 text, html, images = markdown_with_inline_image_support(text)
841 # local paths have been normalised to URLs:
842 imgpath3 = f"file://{imgpath3}"
844 assert 'src="cid:' in html
845 assert "](cid:" in text
846 assert len(images) == 3
847 assert imgpath1 in images
848 assert imgpath2 in images
849 assert imgpath3 in images
850 assert images[imgpath1].cid != images[imgpath2].cid
851 assert images[imgpath1].cid != images[imgpath3].cid
852 assert images[imgpath2].cid != images[imgpath3].cid
854 def test_markdown_inline_image_processor_title_to_desc(self, const1):
855 imgpath = "file:///path/to/image.png"
856 text = f'![inline local image]({imgpath} "{const1}")'
857 text, html, images = markdown_with_inline_image_support(text)
858 assert images[imgpath].desc == const1
860 def test_markdown_inline_image_processor_alt_to_desc(self, const1):
861 imgpath = "file:///path/to/image.png"
862 text = f"![{const1}]({imgpath})"
863 text, html, images = markdown_with_inline_image_support(text)
864 assert images[imgpath].desc == const1
866 def test_markdown_inline_image_processor_title_over_alt_desc(
869 imgpath = "file:///path/to/image.png"
870 text = f'![{const1}]({imgpath} "{const2}")'
871 text, html, images = markdown_with_inline_image_support(text)
872 assert images[imgpath].desc == const2
874 def test_markdown_inline_image_not_external(self):
875 imgpath = "https://path/to/image.png"
876 text = f"![inline image]({imgpath})"
877 text, html, images = markdown_with_inline_image_support(text)
879 assert 'src="cid:' not in html
880 assert "](cid:" not in text
881 assert len(images) == 0
883 def test_markdown_inline_image_local_file(self):
884 imgpath = "/path/to/image.png"
885 text = f"![inline image]({imgpath})"
886 text, html, images = markdown_with_inline_image_support(text)
888 for k, v in images.items():
889 assert k == f"file://{imgpath}"
895 "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE"
896 "AAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAA"
899 def test_markdown_inline_image_processor_base64(self, test_png):
900 text = f"![1px white inlined]({test_png})"
901 text, html, images = markdown_with_inline_image_support(text)
903 assert 'src="cid:' in html
904 assert "](cid:" in text
905 assert len(images) == 1
906 assert test_png in images
908 def test_converter_tree_inline_image_base64(
909 self, test_png, const1, fake_filewriter
911 text = f"![inline base64 image]({test_png})"
912 path = pathlib.Path(const1)
913 tree = convert_markdown_to_html(
914 text, path, filewriter_fn=fake_filewriter
917 assert tree.subtype == "relative"
918 assert tree.children[1].subtype == "png"
919 written = fake_filewriter.pop()
920 assert tree.children[1].path == written[0]
921 assert written[1] == request.urlopen(test_png).read()
923 def test_inline_image_collection(self, test_png, const1, const2, fake_filewriter):
925 test_png: InlineImageInfo(
926 cid=const1, desc=const2
929 relparts = collect_inline_images(
930 test_images, filewriter_fn=fake_filewriter
933 written = fake_filewriter.pop()
934 assert b'PNG' in written[1]
936 assert relparts[0].subtype == "png"
937 assert relparts[0].path == written[0]
938 assert relparts[0].cid == const1
939 assert relparts[0].desc == const2