def test_mkdir(self, _PathMock): path = MagicMock() path.exists.return_value = False parent_path = MagicMock() parent_path.exists.return_value = True parent_path.is_dir.return_value = False top_parent_path = MagicMock() top_parent_path.exists.return_value = True top_parent_path.is_dir.return_value = True path.parents = [parent_path, top_parent_path] path_finder = PathFinder(path) path_finder.mkdir(force=True) parent_path.unlink.assert_called_with() parent_path.mkdir.assert_called_with() path.mkdir.assert_called_with() with self.assertRaises(PathFinderError): path_finder.mkdir(force=False)
class MDDocument(object): """ Markdown file builder. Can be used as a context manager, on exit context is written to `path`. Examples:: md_doc = MDDocument(path=Path('output.md')) md_doc.append('## New section') md_doc.append('some content') md_doc.title = 'My doc' md_doc.add_toc_if_not_exists() md_doc.write() # output is indented for readability Path('output.md').read_text() '''# My doc - [My doc](#my-doc) - [New section](#new-section) ## New section some content ''' with MDDocument(path=Path('output.md')) as md_document: md_document.title = 'My doc' md_doc.append_title('New section', level=2) md_doc.append('New line') Arguments: path -- Path to store document. """ # Indent in spaces for nested ToC lines TOC_INDENT = 4 _anchor_re = re.compile(r"[^a-z0-9_-]+") _escape_title_re = re.compile(r"(_+\S+_+)$") _section_separator = "\n\n" def __init__(self, path): # type: (Path) -> None self._sections = [] # type: List[Text] self._content = "" self._title = "" self._subtitle = "" self._toc_section = "" self._path = path self._path_finder = PathFinder(self._path.parent) def __enter__(self): # type: () -> MDDocument return self def __exit__( self, exc_type, # type: Optional[Type[BaseException]] exc_value, # type: Optional[BaseException] tb, # type: Optional[TracebackType] ): # type: (...) -> None if exc_value: traceback.print_tb(tb) raise exc_value return self.write() def read(self, source_path=None): # type: (Optional[Path]) -> None """ Read and parse content from `source_path`. Arguments: source_path -- Input file path. If not provided - `path` is used. """ path = source_path or self._path self._content = path.read_text() self._title = "" self._toc_section = "" title, content = extract_md_title(self._content) if title: self._title = title sections = content.split(self._section_separator) self._sections = [] for section in sections: section = IndentTrimmer.trim_empty_lines(section) if not section: continue if self.is_toc(section) and not self._toc_section: self._toc_section = section if self._sections: self._subtitle = self._section_separator.join( self._sections) self._sections = [] continue self._sections.append(section) # extract subtitle from the first section if it is not a title if not self._subtitle and self._sections and not self._sections[ 0].startswith("#"): self._subtitle = self._sections.pop(0) def add_toc_if_not_exists(self): # type: () -> None """ Check if ToC exists in the document or create one. """ if not self._toc_section: self._toc_section = self.generate_toc_section() @classmethod def get_anchor(cls, title): # type: (Text) -> Text """ Convert title to a GitHub-friendly anchor link. Returns: A test of anchor link. """ title = title.lower().replace(" ", "-") result = cls._anchor_re.sub("", title) return result @staticmethod def is_toc(section): # type: (Text) -> bool """ Check if the section is Tree of Contents. Returns: True the section is ToC. """ lines = section.split("\n") if len(lines) < 2: return False for line in lines: if "- [" not in line: return False return True @classmethod def render_link(cls, title, link): # type: (Text, Text) -> Text """ Render Markdown link wih escaped title. Examples:: MDDocument.render_link('my title', 'doc.md#test') '[my title](doc.md#test)' MDDocument.render_link('MyClass.__init__', 'my.md') '[MyClass.__init__](doc.md#my.md)' Arguments: title -- Link text. link -- Link target. Returns: A string with Markdown link. """ return "[{}]({})".format(title, link) def render_md_doc_link(self, target_md_document, title=None): # type: (MDDocument, Optional[Text]) -> Text """ Render Markdown link to `target_md_document` header path with a correct title. Arguments: target_md_document -- Target `MDDocument`. title -- Link text. If not provided `target_md_document.title` is used. Returns: A string with Markdown link. """ return self.render_doc_link( title=title or target_md_document.title, anchor=self.get_anchor(target_md_document.title), target_path=target_md_document.path, ) def render_doc_link(self, title, anchor="", target_path=None): # type: (Text, Text, Optional[Path]) -> Text """ Render Markdown link to a local MD document, use relative path as a link. Examples:: md_doc = MDDocument(path='/root/parent/doc.md') MDDocument.render_doc_link( 'my title', anchor='my-anchor', target_path=Path('/root/parent/doc.md' ) '[my title](#my-anchor)' MDDocument.render_doc_link('my title', target_path=Path('/root/parent/other.md')) '[my title](other.md)' MDDocument.render_doc_link('my title', anchor='my-anchor', target_path=Path('doc.md')) '[my title](doc.md#my-anchor)' MDDocument.render_doc_link('my title', anchor='my-anchor') '[my title](#my-anchor)' Arguments: title -- Link text. anchor -- Unescaped or escaped anchor tag. target_path -- Target MDDocument path. Returns: A string with Markdown link. """ link = "" if anchor: link = "#{}".format(anchor) if target_path and target_path != self._path: link_path = self._path_finder.relative(target_path) link = "{}{}".format(link_path, link) return self.render_link(title, link) def _build_content(self): # type: () -> Text sections = [] if self._title: sections.append("# {}".format(self._title)) if self._subtitle: sections.append(self._subtitle) if self._toc_section: sections.append(self._toc_section) sections.extend(self._sections) return self._section_separator.join(sections) + "\n" def write(self): # type: () -> None """ Write MD content to `path`. """ content = self._build_content() self._path_finder.mkdir() self._path.write_text(content) @property def title(self): # type: () -> Text """ `MDDocument` title or an empty string. """ return self._title @title.setter def title(self, title): # type: (Text) -> None self._title = title self._content = self._build_content() @property def subtitle(self): # type: () -> Text """ `MDDocument` subtitle or an empty string. """ return self._subtitle @subtitle.setter def subtitle(self, subtitle): # type: (Text) -> None self._subtitle = subtitle self._content = self._build_content() @property def toc_section(self): # type: () -> Text """ Document Tree of Contents section or an empty line. """ return self._toc_section @toc_section.setter def toc_section(self, toc_section): # type: (Text) -> None self._toc_section = toc_section self._content = self._build_content() @property def sections(self): # type: () -> List[Text] """ All non-special `sections` of the document. """ return self._sections @property def path(self): # type: () -> Path """ Output path of the document. """ return self._path def append(self, content): # type: (Text) -> None """ Append `content` to the document. Handle trimming and sectioning the content and update `title` and `toc_section` fields. Arguments: content -- Text to add. """ content = IndentTrimmer.trim_empty_lines(content) if not content: return if not self.subtitle and not self.sections and not content.startswith( "#"): self.subtitle = content else: self._sections.append(content) self._content = self._build_content() def append_title(self, title, level): # type: (Text, int) -> None """ Append `title` of a given `level` to the document. Handle trimming and sectioning the content and update `title` and `toc_section` fields. Arguments: title -- Title to add. level -- Title level, number of `#` symbols. """ section = "{} {}".format("#" * level, self._escape_title(title)) self._sections.append(section) self._content = self._build_content() def generate_toc_section(self, max_depth=3): # type: (int) -> Text """ Generate Table of Contents MD content. Arguments: max_depth -- Add headers to ToC only up to this level. Returns: A string with ToC. """ toc_lines = [] if self.title: link = self.render_doc_link(self.title, anchor=self.get_anchor(self.title)) toc_line = self.get_toc_line(link, level=0) toc_lines.append(toc_line) sections = [self.title, self.subtitle] + self.sections for section in sections: if not section.startswith("#"): continue if "\n" in section: continue if "# " not in section: continue section = section.rstrip() header_symbols, title = section.split(" ", 1) title = title.strip() if not title: continue if header_symbols.replace("#", ""): continue header_level = len(header_symbols) if header_level > max_depth: continue link = self.render_doc_link(title, anchor=self.get_anchor(title)) toc_line = self.get_toc_line(link, level=header_level - 1) toc_lines.append(toc_line) return "\n".join(toc_lines) @classmethod def get_toc_line(cls, line, level=0): # type: (Text, int) -> Text """ Get ToC `line` of given `level`. Arguments: line -- Line to prepare. level -- Line level, starts with `0`. Returns: Ready to insert ToC line. """ indent = cls.TOC_INDENT * level return IndentTrimmer.indent_line("- {}".format(line), indent) @classmethod def _escape_title(cls, title): # type: (Text) -> Text for match in cls._escape_title_re.findall(title): title = title.replace(match, match.replace("_", "\\_")) return title