diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 28d6548..1526ca6 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -12,7 +12,7 @@ jobs: repo-token: "${{ secrets.GITHUB_TOKEN }}" configuration-path: .github/pr-labeler-file-path.yml # workaround for problem: https://github.com/wesnoth/wesnoth/commit/958c82d0867568057caaf58356502ec8c87d8366 - sync-labels: "" + sync-labels: false - uses: TimonVS/pr-labeler-action@v3 with: configuration-path: .github/pr-labeler-branch-name.yml diff --git a/README.md b/README.md index 9370673..73ffc38 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,15 @@ lazydocs [OPTIONS] PATHS... * `--output-path TEXT`: The output path for the creation of the markdown files. Set this to `stdout` to print all markdown to stdout. [default: ./docs/] * `--src-base-url TEXT`: The base repo link used as prefix for all source links. Should also include the branch name. +* `--url-line-prefix TEXT`: Line prefix for git repository line url anchors #{prefix}line. If None provided, defaults to Github style notation. * `--overview-file TEXT`: Filename of overview file. If not provided, no API overview file will be generated. * `--remove-package-prefix / --no-remove-package-prefix`: If `True`, the package prefix will be removed from all functions and methods. [default: True] * `--ignored-modules TEXT`: A list of modules that should be ignored. [default: ] * `--watermark / --no-watermark`: If `True`, add a watermark with a timestamp to bottom of the markdown files. [default: True] * `--validate / --no-validate`: If `True`, validate the docstrings via pydocstyle. Requires pydocstyle to be installed. [default: False] +* `--output-format TEXT`: The output format for the creation of the markdown files. This may be 'md' or 'mdx'. Defaults to md. +* `--private-modules / --no-private-modules`: If `True`, includes modules with "_" prefix. [default: False] +* `--toc / --no-toc`: If `True`, includes table of contents in generated module markdown files. [default: False] * `--install-completion`: Install completion for the current shell. * `--show-completion`: Show completion for the current shell, to copy it or customize the installation. * `--help`: Show this message and exit. diff --git a/docs/lazydocs.generation.md b/docs/lazydocs.generation.md index 1b21093..1f2d163 100644 --- a/docs/lazydocs.generation.md +++ b/docs/lazydocs.generation.md @@ -1,14 +1,28 @@ - + # module `lazydocs.generation` -Main module for markdown generation. +Main module for markdown generation. + + +## Table of Contents +- [`MarkdownGenerator`](./lazydocs.generation.md#class-markdowngenerator) + - [`__init__`](./lazydocs.generation.md#constructor-__init__) + - [`class2md`](./lazydocs.generation.md#method-class2md) + - [`func2md`](./lazydocs.generation.md#method-func2md) + - [`import2md`](./lazydocs.generation.md#method-import2md) + - [`module2md`](./lazydocs.generation.md#method-module2md) + - [`overview2md`](./lazydocs.generation.md#method-overview2md) + - [`toc2md`](./lazydocs.generation.md#method-toc2md) +- [`generate_docs`](./lazydocs.generation.md#function-generate_docs) +- [`to_md_file`](./lazydocs.generation.md#function-to_md_file) + --- - + ## function `to_md_file` @@ -18,26 +32,28 @@ to_md_file( filename: str, out_path: str = '.', watermark: bool = True, - disable_markdownlint: bool = True + disable_markdownlint: bool = True, + is_mdx: bool = False ) → None ``` -Creates an API docs file from a provided text. - +Creates an API docs file from a provided text. **Args:** - - - `markdown_str` (str): Markdown string with line breaks to write to file. - - `filename` (str): Filename without the .md - - `watermark` (bool): If `True`, add a watermark with a timestamp to bottom of the markdown files. - - `disable_markdownlint` (bool): If `True`, an inline tag is added to disable markdownlint for this file. - - `out_path` (str): The output directory + +- `markdown_str` (str): Markdown string with line breaks to write to file. +- `filename` (str): Filename without the .md +- `out_path` (str): The output directory. +- `watermark` (bool): If `True`, add a watermark with a timestamp to bottom of the markdown files. +- `disable_markdownlint` (bool): If `True`, an inline tag is added to disable markdownlint for this file. +- `is_mdx` (bool, optional): JSX support. Default to False. + --- - + ## function `generate_docs` @@ -49,173 +65,225 @@ generate_docs( src_base_url: Optional[str] = None, remove_package_prefix: bool = False, ignored_modules: Optional[List[str]] = None, + output_format: Optional[str] = None, overview_file: Optional[str] = None, watermark: bool = True, - validate: bool = False + validate: bool = False, + private_modules: bool = False, + include_toc: bool = False, + url_line_prefix: Optional[str] = None ) → None ``` -Generates markdown documentation for provided paths based on Google-style docstrings. - +Generates markdown documentation for provided paths based on Google-style docstrings. **Args:** - - - `paths`: Selected paths or import name for markdown generation. - - `output_path`: The output path for the creation of the markdown files. Set this to `stdout` to print all markdown to stdout. - - `src_root_path`: The root folder name containing all the sources. Fallback to git repo root. - - `src_base_url`: The base url of the github link. Should include branch name. All source links are generated with this prefix. - - `remove_package_prefix`: If `True`, the package prefix will be removed from all functions and methods. - - `ignored_modules`: A list of modules that should be ignored. - - `overview_file`: Filename of overview file. If not provided, no overview file will be generated. - - `watermark`: If `True`, add a watermark with a timestamp to bottom of the markdown files. - - `validate`: If `True`, validate the docstrings via pydocstyle. Requires pydocstyle to be installed. + +- `paths`: Selected paths or import name for markdown generation. +- `output_path`: The output path for the creation of the markdown files. Set this to `stdout` to print all markdown to stdout. +- `src_root_path`: The root folder name containing all the sources. Fallback to git repo root. +- `src_base_url`: The base url of the github link. Should include branch name. All source links are generated with this prefix. +- `remove_package_prefix`: If `True`, the package prefix will be removed from all functions and methods. +- `ignored_modules`: A list of modules that should be ignored. +- `output_format`: Markdown file extension and format. +- `overview_file`: Filename of overview file. If not provided, no overview file will be generated. +- `watermark`: If `True`, add a watermark with a timestamp to bottom of the markdown files. +- `validate`: If `True`, validate the docstrings via pydocstyle. Requires pydocstyle to be installed. +- `private_modules`: If `True`, includes modules with `_` prefix. +- `url_line_prefix: Line prefix for git repository line url anchors. Default`: None - github "L". + --- - + ## class `MarkdownGenerator` -Markdown generator class. +Markdown generator class. - -### method `__init__` + + +### constructor `__init__` ```python -__init__( +MarkdownGenerator( src_root_path: Optional[str] = None, src_base_url: Optional[str] = None, - remove_package_prefix: bool = False + remove_package_prefix: bool = False, + url_line_prefix: Optional[str] = None ) ``` -Initializes the markdown API generator. - +Initializes the markdown API generator. **Args:** - - - `src_root_path`: The root folder name containing all the sources. - - `src_base_url`: The base github link. Should include branch name. All source links are generated with this prefix. - - `remove_package_prefix`: If `True`, the package prefix will be removed from all functions and methods. + +- `src_root_path`: The root folder name containing all the sources. +- `src_base_url`: The base github link. Should include branch name. + All source links are generated with this prefix. +- `remove_package_prefix`: If `True`, the package prefix will be removed from all functions and methods. +- `url_line_prefix: Line prefix for git repository line url anchors. Default`: None - github "L". + --- - + ### method `class2md` ```python -class2md(cls: Any, depth: int = 2) → str +class2md(cls: Any, depth: int = 2, is_mdx: bool = False) → str ``` -Takes a class and creates markdown text to document its methods and variables. - +Takes a class and creates markdown text to document its methods and variables. **Args:** - - - `cls` (class): Selected class for markdown generation. - - `depth` (int, optional): Number of # to append to function name. Defaults to 2. +- `cls` (class): Selected class for markdown generation. +- `depth` (int, optional): Number of # to append to function name. Defaults to 2. +- `is_mdx` (bool, optional): JSX support. Default to False. **Returns:** - - - `str`: Markdown documentation for selected class. + +- `str`: Markdown documentation for selected class. + --- - + ### method `func2md` ```python -func2md(func: Callable, clsname: str = '', depth: int = 3) → str +func2md( + func: Callable, + clsname: str = '', + depth: int = 3, + is_mdx: bool = False +) → str ``` -Takes a function (or method) and generates markdown docs. - +Takes a function (or method) and generates markdown docs. **Args:** - - - `func` (Callable): Selected function (or method) for markdown generation. - - `clsname` (str, optional): Class name to prepend to funcname. Defaults to "". - - `depth` (int, optional): Number of # to append to class name. Defaults to 3. +- `func` (Callable): Selected function (or method) for markdown generation. +- `clsname` (str, optional): Class name to prepend to funcname. Defaults to "". +- `depth` (int, optional): Number of # to append to class name. Defaults to 3. +- `is_mdx` (bool, optional): JSX support. Default to False. **Returns:** - - - `str`: Markdown documentation for selected function. + +- `str`: Markdown documentation for selected function. + --- - + ### method `import2md` ```python -import2md(obj: Any, depth: int = 1) → str +import2md( + obj: Any, + depth: int = 1, + is_mdx: bool = False, + include_toc: bool = False +) → str ``` -Generates markdown documentation for a selected object/import. - +Generates markdown documentation for a selected object/import. **Args:** - - - `obj` (Any): Selcted object for markdown docs generation. - - `depth` (int, optional): Number of # to append before heading. Defaults to 1. +- `obj` (Any): Selcted object for markdown docs generation. +- `depth` (int, optional): Number of # to append before heading. Defaults to 1. +- `is_mdx` (bool, optional): JSX support. Default to False. +- `include_toc` (bool, Optional): Include table of contents for module file. Defaults to False. **Returns:** - - - `str`: Markdown documentation of selected object. + +- `str`: Markdown documentation of selected object. + --- - + ### method `module2md` ```python -module2md(module: module, depth: int = 1) → str +module2md( + module: module, + depth: int = 1, + is_mdx: bool = False, + include_toc: bool = False +) → str ``` -Takes an imported module object and create a Markdown string containing functions and classes. - +Takes an imported module object and create a Markdown string containing functions and classes. **Args:** - - - `module` (types.ModuleType): Selected module for markdown generation. - - `depth` (int, optional): Number of # to append before module heading. Defaults to 1. +- `module` (types.ModuleType): Selected module for markdown generation. +- `depth` (int, optional): Number of # to append before module heading. Defaults to 1. +- `is_mdx` (bool, optional): JSX support. Default to False. +- `include_toc` (bool, optional): Include table of contents in module file. Defaults to False. **Returns:** - - - `str`: Markdown documentation for selected module. + +- `str`: Markdown documentation for selected module. + --- - + ### method `overview2md` ```python -overview2md() → str +overview2md(is_mdx: bool = False) → str +``` + +Generates a documentation overview file based on the generated docs. + + +**Args:** + +- `is_mdx` (bool, optional): JSX support. Default to False. + + +**Returns:** + +- `str`: Markdown documentation of overview file. + + +--- + + + +### method `toc2md` + +```python +toc2md(module: module = None, is_mdx: bool = False) → str ``` -Generates a documentation overview file based on the generated docs. +Generates table of contents for imported object. + diff --git a/src/lazydocs/_about.py b/src/lazydocs/_about.py index a7b66d7..1092556 100644 --- a/src/lazydocs/_about.py +++ b/src/lazydocs/_about.py @@ -1,5 +1,5 @@ """Information about this library. This file will automatically changed.""" -__version__ = "0.5.1" +__version__ = "0.6.0" # __author__ # __email__ diff --git a/src/lazydocs/_cli.py b/src/lazydocs/_cli.py index ac139cd..47c0e36 100644 --- a/src/lazydocs/_cli.py +++ b/src/lazydocs/_cli.py @@ -45,8 +45,19 @@ def generate( output_format: Optional[str] = typer.Option( None, help="The output format for the creation of the markdown files. This may be 'md' or 'mdx'. Defaults to md.", - ) - + ), + private_modules: bool = typer.Option( + False, + help="If `True`, all packages starting with `_` will be included.", + ), + toc: bool = typer.Option( + False, + help="Include table of contents in module file. Defaults to False.", + ), + url_line_prefix: Optional[str] = typer.Option( + None, + help="Line prefix for git repository line url anchors #{prefix}line. If none provided, defaults to Github style notation.", + ), ) -> None: """Generates markdown documentation for your Python project based on Google-style docstrings.""" @@ -61,6 +72,9 @@ def generate( overview_file=overview_file, watermark=watermark, validate=validate, + private_modules=private_modules, + include_toc=toc, + url_line_prefix=url_line_prefix, ) except Exception as ex: typer.echo(str(ex)) diff --git a/src/lazydocs/generation.py b/src/lazydocs/generation.py index 8196665..e773445 100755 --- a/src/lazydocs/generation.py +++ b/src/lazydocs/generation.py @@ -9,20 +9,33 @@ import re import subprocess import types +from dataclasses import dataclass, is_dataclass +from enum import Enum from pydoc import locate from typing import Any, Callable, Dict, List, Optional +from urllib.parse import quote _RE_BLOCKSTART_LIST = re.compile( - r"(Args:|Arg:|Arguments:|Parameters:|Kwargs:|Attributes:|Returns:|Yields:|Kwargs:|Raises:).{0,2}$", + r"^(Args:|Arg:|Arguments:|Parameters:|Kwargs:|Attributes:|Returns:|Yields:|Kwargs:|Raises:).{0,2}$", re.IGNORECASE, ) -_RE_BLOCKSTART_TEXT = re.compile(r"(Examples:|Example:|Todo:).{0,2}$", re.IGNORECASE) +_RE_BLOCKSTART_TEXT = re.compile( + r"^(Example[s]?:|Todo:|Reference[s]?:).{0,2}$", + re.IGNORECASE +) + +# https://github.com/orgs/community/discussions/16925 +# https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts +_RE_ADMONITION_TEXT = re.compile( + r"^(?:\[\!?)?(NOTE|TIP|IMPORTANT|WARNING|CAUTION)s?[\]:][^:]?[ ]*(.*)$", + re.IGNORECASE +) -_RE_QUOTE_TEXT = re.compile(r"(Notes:|Note:).{0,2}$", re.IGNORECASE) +_RE_TYPED_ARGSTART = re.compile(r"^([\w\[\]_]{1,}?)[ ]*?\((.*?)\):[ ]+(.{2,})", re.IGNORECASE) +_RE_ARGSTART = re.compile(r"^(.+):[ ]+(.{2,})$", re.IGNORECASE) -_RE_TYPED_ARGSTART = re.compile(r"([\w\[\]_]{1,}?)\s*?\((.*?)\):(.{2,})", re.IGNORECASE) -_RE_ARGSTART = re.compile(r"(.{1,}?):(.{2,})", re.IGNORECASE) +_RE_CODE_TEXT = re.compile(r"^```[\w\-\.]*[ ]*$", re.IGNORECASE) _IGNORE_GENERATION_INSTRUCTION = "lazydocs: ignore" @@ -51,8 +64,13 @@ """ +_TOC_TEMPLATE = """ +## Table of Contents +{toc} +""" + _CLASS_TEMPLATE = """ -{section} class `{header}` +{section} {kind} `{header}` {doc} {init} {variables} @@ -63,6 +81,7 @@ _MODULE_TEMPLATE = """ {section} module `{header}` {doc} +{toc} {global_vars} {functions} {classes} @@ -125,7 +144,10 @@ def _get_function_signature( if owner_class: name_parts.append(owner_class.__name__) if hasattr(function, "__name__"): - name_parts.append(function.__name__) + if function.__name__ == "__init__": + name_parts.append(_get_class_that_defined_method(function).__name__) + else: + name_parts.append(function.__name__) else: name_parts.append(type(function).__name__) name_parts.append("__call__") @@ -211,16 +233,17 @@ def to_md_file( Args: markdown_str (str): Markdown string with line breaks to write to file. filename (str): Filename without the .md + out_path (str): The output directory. watermark (bool): If `True`, add a watermark with a timestamp to bottom of the markdown files. disable_markdownlint (bool): If `True`, an inline tag is added to disable markdownlint for this file. - out_path (str): The output directory + is_mdx (bool, optional): JSX support. Default to False. """ if not markdown_str: # Dont write empty files return md_file = filename - + if is_mdx: if not filename.endswith(".mdx"): md_file = filename + ".mdx" @@ -237,7 +260,7 @@ def to_md_file( ) print("Writing {}.".format(md_file)) - with open(os.path.join(out_path, md_file), "w", encoding="utf-8") as f: + with open(os.path.join(out_path, md_file), "w", encoding="utf-8", newline="\n") as f: f.write(markdown_str) @@ -275,12 +298,19 @@ def _get_class_that_defined_method(meth: Any) -> Any: mod = inspect.getmodule(meth) if mod is None: return None - cls = getattr( - inspect.getmodule(meth), - meth.__qualname__.split(".", 1)[0].rsplit(".", 1)[0], - ) - if isinstance(cls, type): - return cls + try: + cls = getattr( + inspect.getmodule(meth), + meth.__qualname__.split(".", 1)[0].rsplit(".", 1)[0], + ) + except AttributeError: + # workaround for AttributeError("module '' has no attribute '__create_fn__'") + for obj in meth.__globals__.values(): + if is_dataclass(obj): + return obj + else: + if isinstance(cls, type): + return cls return getattr(meth, "__objclass__", None) # handle special descriptor objects @@ -298,9 +328,9 @@ def _is_object_ignored(obj: Any) -> bool: return False -def _is_module_ignored(module_name: str, ignored_modules: List[str]) -> bool: +def _is_module_ignored(module_name: str, ignored_modules: List[str], private_modules: bool = False) -> bool: """Checks if a given module is ignored.""" - if module_name.split(".")[-1].startswith("_"): + if module_name.split(".")[-1].startswith("_") and module_name[1] != "_" and not private_modules: return True for ignored_module in ignored_modules: @@ -360,106 +390,239 @@ def _doc2md(obj: Any) -> str: # doc = getdoc(func) or "" doc = _get_docstring(obj) + padding = 0 blockindent = 0 - argindent = 1 + argindent = 0 out = [] arg_list = False - literal_block = False + section_block = False + block_exit = False md_code_snippet = False - quote_block = False + admonition_block = None + literal_block = None + doctest_block = None + prev_blank_line_count = 0 + offset = 0 - for line in doc.split("\n"): - indent = len(line) - len(line.lstrip()) - if not md_code_snippet and not literal_block: - line = line.lstrip() + @dataclass + class SectionBlock(): + line_index: int + indent: int + offset: int - if line.startswith(">>>"): - # support for doctest - line = line.replace(">>>", "```") + "```" + def _get_section_offset(lines: list, start_index: int, blockindent: int): + """Determine base padding offset for section. - if ( - _RE_BLOCKSTART_LIST.match(line) - or _RE_BLOCKSTART_TEXT.match(line) - or _RE_QUOTE_TEXT.match(line) - ): - # start of a new block - blockindent = indent - - if quote_block: - quote_block = False + Args: + lines (list): Line lists. + start_index (int): Index of lines to start parsing. + blockindent (int): Reference block indent of section. - if literal_block: - # break literal block - out.append("```\n") - literal_block = False + Returns: + int: Padding offset. + """ + offset = [] + try: + for line in lines[start_index:]: + indent = len(line) - len(line.lstrip()) + if not line.strip(): + continue + if indent <= blockindent: + return -min(offset) if offset else 0 + if indent > blockindent: + offset.append(indent - blockindent) + except IndexError: + return 0 + return -min(offset) if offset else 0 + + def _lines_isvalid(lines: list, start_index: int, blockindent: int, + allow_same_level: bool = False, + require_next_is_blank: bool = False, + max_blank: int = None): + """Determine following lines fit section rules. - out.append("\n\n**{}**\n".format(line.strip())) + Args: + lines (list): Line lists. + start_index (int): Index of lines to start parsing. + blockindent (int): Reference block indent of section. + allow_same_level (bool, optional): Allow line indent as blockindent. Defaults to False. + require_next_is_blank (bool, optional): Require first parsed line to be blank. Defaults to False. + max_blank (int, optional): Max number of allowable continuous blank lines in section. Defaults to None. - arg_list = bool(_RE_BLOCKSTART_LIST.match(line)) + Returns: + bool: Validity of tested lines. + """ + prev_blank = 0 + try: + for index, line in enumerate(lines[start_index:]): + indent = len(line) - len(line.lstrip()) + line = line.strip() + if require_next_is_blank and index == 0 and line: + return False + if line: + prev_blank = 0 + if indent <= blockindent: + if allow_same_level and indent == blockindent: + return True + return False + return True + if max_blank is not None: + if not line: + prev_blank += 1 + if prev_blank > max_blank: + return False + except IndexError: + pass + return False - if _RE_QUOTE_TEXT.match(line): - quote_block = True - out.append("\n>") - elif line.strip().startswith("```"): - # Code snippet is used - if md_code_snippet: - md_code_snippet = False - else: + docstring = doc.split("\n") + for line_indx, line in enumerate(docstring): + indent = len(line) - len(line.lstrip()) + line = line.lstrip() + offset = 0 + + # Exit condition for args and section blocks + if (any([arg_list, section_block]) + and all([indent <= blockindent, + prev_blank_line_count, + line])): + arg_list = False if arg_list else arg_list + section_block = False if section_block else section_block + blockindent = 0 + + admonition_result = _RE_ADMONITION_TEXT.match(line) + blockstart_result = _RE_BLOCKSTART_LIST.match(line) + blocktext_result = _RE_BLOCKSTART_TEXT.match(line) + + if admonition_result and not (md_code_snippet or admonition_block): + # Admonition block entry condition + admonition_block = SectionBlock( + line_indx, indent, _get_section_offset(docstring, + line_indx + 1, + indent)) + line = "[!{}] {}".format(admonition_result.group(1).upper(), + admonition_result.group(2)) + + # Entry conditions and block offsets + if _RE_CODE_TEXT.match(line): + # Code block, detect "```" + md_code_snippet = not md_code_snippet + elif line.startswith(">>>") and not doctest_block: + # Doctest Entry condition + line = "```python\n" + line + if _lines_isvalid(docstring, line_indx + 1, indent, True, False, 1): + doctest_block = SectionBlock(line_indx, indent, 0) md_code_snippet = True + else: + line = line + "\n```" + elif doctest_block and \ + not _lines_isvalid(docstring, line_indx + 1, doctest_block.indent, + True, False, 1): + # Doctest block Exit Condition + offset = doctest_block.indent - indent + line = " " * (indent - doctest_block.indent + + doctest_block.offset) + line + "\n```" + block_exit = True + elif line.endswith("::") and not (literal_block) and \ + _lines_isvalid(docstring, line_indx + 1, indent, False, True, None): + # Literal Block Entry Conditions + literal_block = SectionBlock( + line_indx, indent, + _get_section_offset(docstring, line_indx + 1, indent)) + line = line.replace("::", "") if line.startswith( + "::") else line.replace("::", ":") + md_code_snippet = True + elif literal_block: + if line_indx == literal_block.line_index + 1 and not line: + # Literal block post entry + line = "```" + line + indent = literal_block.indent + elif not _lines_isvalid(docstring, line_indx + 1, literal_block.indent, + False, False, None): + # Literal block exit condition + offset += literal_block.indent - indent + line = " " * (indent - literal_block.indent + + literal_block.offset) + line + "\n```" + block_exit = True + elif line: + offset += literal_block.offset + + # Admonition block processing and exit condition + if admonition_block: + if md_code_snippet: + if literal_block: + padding = max(indent - literal_block.indent, 0) + elif doctest_block: + padding = max(indent - doctest_block.indent, 0) + else: + padding = max(indent - admonition_block.indent + + admonition_block.offset, 0) + line = " " * (padding + offset) + line + offset = admonition_block.indent - indent + line = "> {}".format(line.replace("\n", "\n> ")) + if not _lines_isvalid(docstring, line_indx + 1, admonition_block.indent, + False, False, None): + admonition_block = None + + if (blockstart_result or blocktext_result): + # start of a new block + blockindent = indent + arg_list = bool(blockstart_result) + section_block = bool(blocktext_result) - out.append(line) - elif line.strip().endswith("::"): - # Literal Block Support: https://docutils.sourceforge.io/docs/user/rst/quickref.html#literal-blocks - literal_block = True - out.append(line.replace("::", ":\n```")) - elif quote_block: - out.append(line.strip()) - elif line.strip().startswith("-"): - # Allow bullet lists - out.append("\n" + (" " * indent) + line) - elif indent > blockindent: + if prev_blank_line_count <= 1: + out.append("\n") + out.append("**{}**\n".format(line.strip())) + elif indent > blockindent and (arg_list or section_block): if arg_list and not literal_block and _RE_TYPED_ARGSTART.match(line): # start of new argument out.append( - "\n" - + " " * blockindent - + " - " + "- " + _RE_TYPED_ARGSTART.sub(r"`\1` (\2): \3", line) ) argindent = indent elif arg_list and not literal_block and _RE_ARGSTART.match(line): # start of an exception-type block out.append( - "\n" - + " " * blockindent - + " - " + "- " + _RE_ARGSTART.sub(r"`\1`: \2", line) ) argindent = indent elif indent > argindent: # attach docs text of argument # * (blockindent + 2) - out.append(" " + line) + padding = max(indent - argindent + offset, 0) + out.append(" " * padding + + line.replace("\n", + "\n" + " " * padding)) else: - out.append(line) + padding = max(indent - blockindent + offset, 0) + out.append(line.replace("\n", + "\n" + " " * padding)) + elif line: + padding = max(indent - blockindent + offset, 0) + out.append(" " * padding + + line.replace("\n", + "\n" + " " * padding)) else: - if line.strip() and literal_block: - # indent has changed, if not empty line, break literal block - line = "```\n" + line - literal_block = False out.append(line) - if md_code_snippet: - out.append("\n") - elif not line and not quote_block: - out.append("\n\n") - elif not line and quote_block: - out.append("\n>") - else: - out.append(" ") + out.append("\n") - return "".join(out) + if block_exit: + block_exit = False + if md_code_snippet: + md_code_snippet = False + if literal_block: + literal_block = None + elif doctest_block: + doctest_block = None + if line.lstrip(): + prev_blank_line_count = 0 + else: + prev_blank_line_count += 1 + return "".join(out) class MarkdownGenerator(object): """Markdown generator class.""" @@ -469,6 +632,7 @@ def __init__( src_root_path: Optional[str] = None, src_base_url: Optional[str] = None, remove_package_prefix: bool = False, + url_line_prefix: Optional[str] = None, ): """Initializes the markdown API generator. @@ -477,10 +641,12 @@ def __init__( src_base_url: The base github link. Should include branch name. All source links are generated with this prefix. remove_package_prefix: If `True`, the package prefix will be removed from all functions and methods. + url_line_prefix: Line prefix for git repository line url anchors. Default: None - github "L". """ self.src_root_path = src_root_path self.src_base_url = src_base_url self.remove_package_prefix = remove_package_prefix + self.url_line_prefix = url_line_prefix self.generated_objects: List[Dict] = [] @@ -522,14 +688,20 @@ def _get_src_path(self, obj: Any, append_base: bool = True) -> str: relative_path = os.path.relpath(path, src_root_path) lineno = _get_line_no(obj) - lineno_hashtag = "" if lineno is None else "#L{}".format(lineno) + if self.url_line_prefix is None: + lineno_hashtag = "" if lineno is None else "#L{}".format(lineno) + else: + lineno_hashtag = "" if lineno is None else "#{}{}".format( + self.url_line_prefix, + lineno + ) # add line hash relative_path = relative_path + lineno_hashtag if append_base and self.src_base_url: relative_path = os.path.join(self.src_base_url, relative_path) - return relative_path + return quote("/".join(relative_path.split("\\")), safe=":/#") def func2md(self, func: Callable, clsname: str = "", depth: int = 3, is_mdx: bool = False) -> str: """Takes a function (or method) and generates markdown docs. @@ -538,6 +710,7 @@ def func2md(self, func: Callable, clsname: str = "", depth: int = 3, is_mdx: boo func (Callable): Selected function (or method) for markdown generation. clsname (str, optional): Class name to prepend to funcname. Defaults to "". depth (int, optional): Number of # to append to class name. Defaults to 3. + is_mdx (bool, optional): JSX support. Default to False. Returns: str: Markdown documentation for selected function. @@ -589,7 +762,7 @@ def func2md(self, func: Callable, clsname: str = "", depth: int = 3, is_mdx: boo func_type = "function" else: # function of a class - func_type = "method" + func_type = "constructor" if escfuncname == "__init__" else "method" self.generated_objects.append( { @@ -614,7 +787,7 @@ def func2md(self, func: Callable, clsname: str = "", depth: int = 3, is_mdx: boo if path: if is_mdx: markdown = _MDX_SOURCE_BADGE_TEMPLATE.format(path=path) + markdown - else: + else: markdown = _SOURCE_BADGE_TEMPLATE.format(path=path) + markdown return markdown @@ -625,6 +798,7 @@ def class2md(self, cls: Any, depth: int = 2, is_mdx: bool = False) -> str: Args: cls (class): Selected class for markdown generation. depth (int, optional): Number of # to append to function name. Defaults to 2. + is_mdx (bool, optional): JSX support. Default to False. Returns: str: Markdown documentation for selected class. @@ -634,6 +808,7 @@ def class2md(self, cls: Any, depth: int = 2, is_mdx: bool = False) -> str: return "" section = "#" * depth + sectionheader = "#" * (depth + 1) subsection = "#" * (depth + 2) clsname = cls.__name__ modname = cls.__module__ @@ -641,6 +816,27 @@ def class2md(self, cls: Any, depth: int = 2, is_mdx: bool = False) -> str: path = self._get_src_path(cls) doc = _doc2md(cls) summary = _get_doc_summary(cls) + variables = [] + + # Handle different kinds of classes + if issubclass(cls, Enum): + kind = cls.__base__.__name__ + if kind != "Enum": + kind = "enum[%s]" % (kind) + else: + kind = kind.lower() + variables.append( + "%s symbols\n" % (sectionheader) + ) + elif is_dataclass(cls): + kind = "dataclass" + variables.append( + "%s attributes\n" % (sectionheader) + ) + elif issubclass(cls, Exception): + kind = "exception" + else: + kind = "class" self.generated_objects.append( { @@ -648,7 +844,7 @@ def class2md(self, cls: Any, depth: int = 2, is_mdx: bool = False) -> str: "name": header, "full_name": header, "module": modname, - "anchor_tag": _get_anchor_tag("class-" + header), + "anchor_tag": _get_anchor_tag("%s-%s" % (kind, header)), "description": summary, } ) @@ -666,21 +862,31 @@ def class2md(self, cls: Any, depth: int = 2, is_mdx: bool = False) -> str: # this happens if __init__ is outside the repo init = "" - variables = [] for name, obj in inspect.getmembers( cls, lambda a: not (inspect.isroutine(a) or inspect.ismethod(a)) ): - if not name.startswith("_") and type(obj) == property: - comments = _doc2md(obj) or inspect.getcomments(obj) - comments = "\n\n%s" % comments if comments else "" - property_name = f"{clsname}.{name}" + if not name.startswith("_"): + full_name = f"{clsname}.{name}" if self.remove_package_prefix: - property_name = name - variables.append( - _SEPARATOR - + "\n%s property %s%s\n" - % (subsection, property_name, comments) - ) + full_name = name + if isinstance(obj, property): + comments = _doc2md(obj) or inspect.getcomments(obj) + comments = "\n\n%s" % comments if comments else "" + variables.append( + _SEPARATOR + + "\n%s property %s%s\n" + % (subsection, full_name, comments) + ) + elif isinstance(obj, Enum): + variables.append( + "- **%s** = %s\n" % (full_name, obj.value) + ) + elif name == "__dataclass_fields__": + for name, field in sorted((obj).items()): + variables.append( + "- ```%s``` (%s)\n" % (name, + field.type.__name__) + ) handlers = [] for name, obj in inspect.getmembers(cls, inspect.ismethoddescriptor): @@ -717,6 +923,7 @@ def class2md(self, cls: Any, depth: int = 2, is_mdx: bool = False) -> str: markdown = _CLASS_TEMPLATE.format( section=section, + kind=kind, header=header, doc=doc if doc else "", init=init, @@ -733,12 +940,14 @@ def class2md(self, cls: Any, depth: int = 2, is_mdx: bool = False) -> str: return markdown - def module2md(self, module: types.ModuleType, depth: int = 1, is_mdx: bool = False) -> str: + def module2md(self, module: types.ModuleType, depth: int = 1, is_mdx: bool = False, include_toc: bool = False) -> str: """Takes an imported module object and create a Markdown string containing functions and classes. Args: module (types.ModuleType): Selected module for markdown generation. depth (int, optional): Number of # to append before module heading. Defaults to 1. + is_mdx (bool, optional): JSX support. Default to False. + include_toc (bool, optional): Include table of contents in module file. Defaults to False. Returns: str: Markdown documentation for selected module. @@ -814,10 +1023,13 @@ def module2md(self, module: types.ModuleType, depth: int = 1, is_mdx: bool = Fal new_list = ["\n**Global Variables**", "---------------", *variables] variables = new_list + toc = self.toc2md(module=module, is_mdx=is_mdx) if include_toc else "" + markdown = _MODULE_TEMPLATE.format( header=modname, section="#" * depth, doc=doc, + toc=toc, global_vars="\n".join(variables) if variables else "", functions="\n".join(functions) if functions else "", classes="".join(classes) if classes else "", @@ -831,12 +1043,14 @@ def module2md(self, module: types.ModuleType, depth: int = 1, is_mdx: bool = Fal return markdown - def import2md(self, obj: Any, depth: int = 1, is_mdx: bool = False) -> str: + def import2md(self, obj: Any, depth: int = 1, is_mdx: bool = False, include_toc: bool = False) -> str: """Generates markdown documentation for a selected object/import. Args: obj (Any): Selcted object for markdown docs generation. depth (int, optional): Number of # to append before heading. Defaults to 1. + is_mdx (bool, optional): JSX support. Default to False. + include_toc(bool, Optional): Include table of contents for module file. Defaults to False. Returns: str: Markdown documentation of selected object. @@ -844,7 +1058,7 @@ def import2md(self, obj: Any, depth: int = 1, is_mdx: bool = False) -> str: if inspect.isclass(obj): return self.class2md(obj, depth=depth, is_mdx=is_mdx) elif isinstance(obj, types.ModuleType): - return self.module2md(obj, depth=depth, is_mdx=is_mdx) + return self.module2md(obj, depth=depth, is_mdx=is_mdx, include_toc=include_toc) elif callable(obj): return self.func2md(obj, depth=depth, is_mdx=is_mdx) else: @@ -852,7 +1066,14 @@ def import2md(self, obj: Any, depth: int = 1, is_mdx: bool = False) -> str: return "" def overview2md(self, is_mdx: bool = False) -> str: - """Generates a documentation overview file based on the generated docs.""" + """Generates a documentation overview file based on the generated docs. + + Args: + is_mdx (bool, optional): JSX support. Default to False. + + Returns: + str: Markdown documentation of overview file. + """ entries_md = "" for obj in list( @@ -913,6 +1134,26 @@ def overview2md(self, is_mdx: bool = False) -> str: modules=modules_md, classes=classes_md, functions=functions_md ) + def toc2md(self, module: types.ModuleType = None, is_mdx: bool = False) -> str: + """Generates table of contents for imported object.""" + toc = [] + for obj in self.generated_objects: + if module and (module.__name__ != obj["module"] or obj["type"] == "module"): + continue + # module_name = obj["module"].split(".")[-1] + full_name = obj["full_name"] + name = obj["name"] + if is_mdx: + link = "./" + obj["module"] + ".mdx#" + obj["anchor_tag"] + else: + link = "./" + obj["module"] + ".md#" + obj["anchor_tag"] + line = f"- [`{name}`]({link})" + depth = max(len(full_name.split(".")) - 1, 0) + if depth: + line = "\t" * depth + line + toc.append(line) + return _TOC_TEMPLATE.format(toc="\n".join(toc)) + def generate_docs( paths: List[str], @@ -925,6 +1166,9 @@ def generate_docs( overview_file: Optional[str] = None, watermark: bool = True, validate: bool = False, + private_modules: bool = False, + include_toc: bool = False, + url_line_prefix: Optional[str] = None, ) -> None: """Generates markdown documentation for provided paths based on Google-style docstrings. @@ -935,9 +1179,12 @@ def generate_docs( src_base_url: The base url of the github link. Should include branch name. All source links are generated with this prefix. remove_package_prefix: If `True`, the package prefix will be removed from all functions and methods. ignored_modules: A list of modules that should be ignored. + output_format: Markdown file extension and format. overview_file: Filename of overview file. If not provided, no overview file will be generated. watermark: If `True`, add a watermark with a timestamp to bottom of the markdown files. validate: If `True`, validate the docstrings via pydocstyle. Requires pydocstyle to be installed. + private_modules: If `True`, includes modules with `_` prefix. + url_line_prefix: Line prefix for git repository line url anchors. Default: None - github "L". """ stdout_mode = output_path.lower() == "stdout" @@ -978,6 +1225,7 @@ def generate_docs( src_root_path=src_root_path, src_base_url=src_base_url, remove_package_prefix=remove_package_prefix, + url_line_prefix=url_line_prefix, ) pydocstyle_cmd = "pydocstyle --convention=google --add-ignore=D100,D101,D102,D103,D104,D105,D107,D202" @@ -992,15 +1240,19 @@ def generate_docs( # Generate one file for every discovered module for loader, module_name, _ in pkgutil.walk_packages([path]): - if _is_module_ignored(module_name, ignored_modules): + if _is_module_ignored(module_name, ignored_modules, private_modules): # Add module to ignore list, so submodule will also be ignored ignored_modules.append(module_name) continue - try: - mod_spec = loader.find_spec(module_name) - mod = importlib.util.module_from_spec(mod_spec) - module_md = generator.module2md(mod, is_mdx=is_mdx) + try: + mod_spec = importlib.util.spec_from_loader(module_name, loader) + mod = importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + except AttributeError: + # For older python version compatibility + mod = loader.find_module(module_name).load_module(module_name) # type: ignore + module_md = generator.module2md(mod, is_mdx=is_mdx, include_toc=include_toc) if not module_md: # Module md is empty -> ignore module and all submodules # Add module to ignore list, so submodule will also be ignored @@ -1039,7 +1291,7 @@ def generate_docs( spec.loader.exec_module(mod) # type: ignore if mod: - module_md = generator.module2md(mod, is_mdx=is_mdx) + module_md = generator.module2md(mod, is_mdx=is_mdx, include_toc=include_toc) if stdout_mode: print(module_md) else: @@ -1071,15 +1323,20 @@ def generate_docs( path=obj.__path__, # type: ignore prefix=obj.__name__ + ".", # type: ignore ): - if _is_module_ignored(module_name, ignored_modules): + if _is_module_ignored(module_name, ignored_modules, private_modules): # Add module to ignore list, so submodule will also be ignored ignored_modules.append(module_name) continue try: - mod_spec = loader.find_spec(module_name) - mod = importlib.util.module_from_spec(mod_spec) - module_md = generator.module2md(mod, is_mdx=is_mdx) + try: + mod_spec = importlib.util.spec_from_loader(module_name, loader) + mod = importlib.util.module_from_spec(mod_spec) + mod_spec.loader.exec_module(mod) + except AttributeError: + # For older python version compatibility + mod = loader.find_module(module_name).load_module(module_name) # type: ignore + module_md = generator.module2md(mod, is_mdx=is_mdx, include_toc=include_toc) if not module_md: # Module MD is empty -> ignore module and all submodules @@ -1132,5 +1389,5 @@ def generate_docs( # Write mkdocs pages file print("Writing mkdocs .pages file.") # TODO: generate navigation items to fix problem with naming - with open(os.path.join(output_path, ".pages"), "w") as f: + with open(os.path.join(output_path, ".pages"), "w", encoding="utf-8", newline="\n") as f: f.write(_MKDOCS_PAGES_TEMPLATE.format(overview_file=overview_file))