Skip to content

Commit c743882

Browse files
committed
Overhauled docstring render and added Github flavour admonition support
Modify argument regex Fix Colon use in docstring in arguments blocks now formatted correctly. Change argument detection to last colon in line. Added support for "Reference" as a block header. Convert quote block to admonition blocks Added Github admonition quote block support. Added start line anchor to regex Changed "```" code snippet boundary detection from startswith to regex to prevent false positives. Rework docstring markdown render. Solves issue #80 Improved whitespace and newline rendering. Accepts more native markdown syntax without garbling render. Solves Issue #82 Enumerate the docstring to detect end of docstring to appropriately close literal blocks, doctest and code blocks Update literal blocks logic and format. Syntax is same as reStructured text
1 parent 87a3d59 commit c743882

File tree

1 file changed

+215
-71
lines changed

1 file changed

+215
-71
lines changed

src/lazydocs/generation.py

Lines changed: 215 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,31 @@
99
import re
1010
import subprocess
1111
import types
12+
from dataclasses import dataclass
1213
from pydoc import locate
1314
from typing import Any, Callable, Dict, List, Optional
1415

1516
_RE_BLOCKSTART_LIST = re.compile(
16-
r"(Args:|Arg:|Arguments:|Parameters:|Kwargs:|Attributes:|Returns:|Yields:|Kwargs:|Raises:).{0,2}$",
17+
r"^(Args:|Arg:|Arguments:|Parameters:|Kwargs:|Attributes:|Returns:|Yields:|Kwargs:|Raises:).{0,2}$",
1718
re.IGNORECASE,
1819
)
1920

20-
_RE_BLOCKSTART_TEXT = re.compile(r"(Examples:|Example:|Todo:).{0,2}$", re.IGNORECASE)
21+
_RE_BLOCKSTART_TEXT = re.compile(
22+
r"^(Example[s]?:|Todo:|Reference[s]?:).{0,2}$",
23+
re.IGNORECASE
24+
)
25+
26+
# https://github.com/orgs/community/discussions/16925
27+
# https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
28+
_RE_ADMONITION_TEXT = re.compile(
29+
r"^(?:\[\!?)?(NOTE|TIP|IMPORTANT|WARNING|CAUTION)s?[\]:][^:]?[ ]*(.*)$",
30+
re.IGNORECASE
31+
)
2132

22-
_RE_QUOTE_TEXT = re.compile(r"(Notes:|Note:).{0,2}$", re.IGNORECASE)
33+
_RE_TYPED_ARGSTART = re.compile(r"^([\w\[\]_]{1,}?)[ ]*?\((.*?)\):[ ]+(.{2,})", re.IGNORECASE)
34+
_RE_ARGSTART = re.compile(r"^(.+):[ ]+(.{2,})$", re.IGNORECASE)
2335

24-
_RE_TYPED_ARGSTART = re.compile(r"([\w\[\]_]{1,}?)\s*?\((.*?)\):(.{2,})", re.IGNORECASE)
25-
_RE_ARGSTART = re.compile(r"(.{1,}?):(.{2,})", re.IGNORECASE)
36+
_RE_CODE_TEXT = re.compile(r"^```[\w\-\.]*[ ]*$", re.IGNORECASE)
2637

2738
_IGNORE_GENERATION_INSTRUCTION = "lazydocs: ignore"
2839

@@ -361,106 +372,239 @@ def _doc2md(obj: Any) -> str:
361372
# doc = getdoc(func) or ""
362373
doc = _get_docstring(obj)
363374

375+
padding = 0
364376
blockindent = 0
365-
argindent = 1
377+
argindent = 0
366378
out = []
367379
arg_list = False
368-
literal_block = False
380+
section_block = False
381+
block_exit = False
369382
md_code_snippet = False
370-
quote_block = False
383+
admonition_block = None
384+
literal_block = None
385+
doctest_block = None
386+
prev_blank_line_count = 0
387+
offset = 0
371388

372-
for line in doc.split("\n"):
373-
indent = len(line) - len(line.lstrip())
374-
if not md_code_snippet and not literal_block:
375-
line = line.lstrip()
389+
@dataclass
390+
class SectionBlock():
391+
line_index: int
392+
indent: int
393+
offset: int
376394

377-
if line.startswith(">>>"):
378-
# support for doctest
379-
line = line.replace(">>>", "```") + "```"
395+
def _get_section_offset(lines: list, start_index: int, blockindent: int):
396+
"""Determine base padding offset for section.
380397
381-
if (
382-
_RE_BLOCKSTART_LIST.match(line)
383-
or _RE_BLOCKSTART_TEXT.match(line)
384-
or _RE_QUOTE_TEXT.match(line)
385-
):
386-
# start of a new block
387-
blockindent = indent
388-
389-
if quote_block:
390-
quote_block = False
398+
Args:
399+
lines (list): Line lists.
400+
start_index (int): Index of lines to start parsing.
401+
blockindent (int): Reference block indent of section.
391402
392-
if literal_block:
393-
# break literal block
394-
out.append("```\n")
395-
literal_block = False
403+
Returns:
404+
int: Padding offset.
405+
"""
406+
offset = []
407+
try:
408+
for line in lines[start_index:]:
409+
indent = len(line) - len(line.lstrip())
410+
if not line.strip():
411+
continue
412+
if indent <= blockindent:
413+
return -min(offset) if offset else 0
414+
if indent > blockindent:
415+
offset.append(indent - blockindent)
416+
except IndexError:
417+
return 0
418+
return -min(offset) if offset else 0
419+
420+
def _lines_isvalid(lines: list, start_index: int, blockindent: int,
421+
allow_same_level: bool = False,
422+
require_next_is_blank: bool = False,
423+
max_blank: int = None):
424+
"""Determine following lines fit section rules.
396425
397-
out.append("\n\n**{}**\n".format(line.strip()))
426+
Args:
427+
lines (list): Line lists.
428+
start_index (int): Index of lines to start parsing.
429+
blockindent (int): Reference block indent of section.
430+
allow_same_level (bool, optional): Allow line indent as blockindent. Defaults to False.
431+
require_next_is_blank (bool, optional): Require first parsed line to be blank. Defaults to False.
432+
max_blank (int, optional): Max number of allowable continuous blank lines in section. Defaults to None.
398433
399-
arg_list = bool(_RE_BLOCKSTART_LIST.match(line))
434+
Returns:
435+
bool: Validity of tested lines.
436+
"""
437+
prev_blank = 0
438+
try:
439+
for index, line in enumerate(lines[start_index:]):
440+
indent = len(line) - len(line.lstrip())
441+
line = line.strip()
442+
if require_next_is_blank and index == 0 and line:
443+
return False
444+
if line:
445+
prev_blank = 0
446+
if indent <= blockindent:
447+
if allow_same_level and indent == blockindent:
448+
return True
449+
return False
450+
return True
451+
if max_blank is not None:
452+
if not line:
453+
prev_blank += 1
454+
if prev_blank > max_blank:
455+
return False
456+
except IndexError:
457+
pass
458+
return False
400459

401-
if _RE_QUOTE_TEXT.match(line):
402-
quote_block = True
403-
out.append("\n>")
404-
elif line.strip().startswith("```"):
405-
# Code snippet is used
406-
if md_code_snippet:
407-
md_code_snippet = False
408-
else:
460+
docstring = doc.split("\n")
461+
for line_indx, line in enumerate(docstring):
462+
indent = len(line) - len(line.lstrip())
463+
line = line.lstrip()
464+
offset = 0
465+
466+
# Exit condition for args and section blocks
467+
if (any([arg_list, section_block])
468+
and all([indent <= blockindent,
469+
prev_blank_line_count,
470+
line])):
471+
arg_list = False if arg_list else arg_list
472+
section_block = False if section_block else section_block
473+
blockindent = 0
474+
475+
admonition_result = _RE_ADMONITION_TEXT.match(line)
476+
blockstart_result = _RE_BLOCKSTART_LIST.match(line)
477+
blocktext_result = _RE_BLOCKSTART_TEXT.match(line)
478+
479+
if admonition_result and not (md_code_snippet or admonition_block):
480+
# Admonition block entry condition
481+
admonition_block = SectionBlock(
482+
line_indx, indent, _get_section_offset(docstring,
483+
line_indx + 1,
484+
indent))
485+
line = "[!{}] {}".format(admonition_result.group(1).upper(),
486+
admonition_result.group(2))
487+
488+
# Entry conditions and block offsets
489+
if _RE_CODE_TEXT.match(line):
490+
# Code block, detect "```"
491+
md_code_snippet = not md_code_snippet
492+
elif line.startswith(">>>") and not doctest_block:
493+
# Doctest Entry condition
494+
line = "```python\n" + line
495+
if _lines_isvalid(docstring, line_indx + 1, indent, True, False, 1):
496+
doctest_block = SectionBlock(line_indx, indent, 0)
409497
md_code_snippet = True
498+
else:
499+
line = line + "\n```"
500+
elif doctest_block and \
501+
not _lines_isvalid(docstring, line_indx + 1, doctest_block.indent,
502+
True, False, 1):
503+
# Doctest block Exit Condition
504+
offset = doctest_block.indent - indent
505+
line = " " * (indent - doctest_block.indent +
506+
doctest_block.offset) + line + "\n```"
507+
block_exit = True
508+
elif line.endswith("::") and not (literal_block) and \
509+
_lines_isvalid(docstring, line_indx + 1, indent, False, True, None):
510+
# Literal Block Entry Conditions
511+
literal_block = SectionBlock(
512+
line_indx, indent,
513+
_get_section_offset(docstring, line_indx + 1, indent))
514+
line = line.replace("::", "") if line.startswith(
515+
"::") else line.replace("::", ":")
516+
md_code_snippet = True
517+
elif literal_block:
518+
if line_indx == literal_block.line_index + 1 and not line:
519+
# Literal block post entry
520+
line = "```" + line
521+
indent = literal_block.indent
522+
elif not _lines_isvalid(docstring, line_indx + 1, literal_block.indent,
523+
False, False, None):
524+
# Literal block exit condition
525+
offset += literal_block.indent - indent
526+
line = " " * (indent - literal_block.indent +
527+
literal_block.offset) + line + "\n```"
528+
block_exit = True
529+
elif line:
530+
offset += literal_block.offset
531+
532+
# Admonition block processing and exit condition
533+
if admonition_block:
534+
if md_code_snippet:
535+
if literal_block:
536+
padding = max(indent - literal_block.indent, 0)
537+
elif doctest_block:
538+
padding = max(indent - doctest_block.indent, 0)
539+
else:
540+
padding = max(indent - admonition_block.indent
541+
+ admonition_block.offset, 0)
542+
line = " " * (padding + offset) + line
543+
offset = admonition_block.indent - indent
544+
line = "> {}".format(line.replace("\n", "\n> "))
545+
if not _lines_isvalid(docstring, line_indx + 1, admonition_block.indent,
546+
False, False, None):
547+
admonition_block = None
548+
549+
if (blockstart_result or blocktext_result):
550+
# start of a new block
551+
blockindent = indent
552+
arg_list = bool(blockstart_result)
553+
section_block = bool(blocktext_result)
410554

411-
out.append(line)
412-
elif line.strip().endswith("::"):
413-
# Literal Block Support: https://docutils.sourceforge.io/docs/user/rst/quickref.html#literal-blocks
414-
literal_block = True
415-
out.append(line.replace("::", ":\n```"))
416-
elif quote_block:
417-
out.append(line.strip())
418-
elif line.strip().startswith("-"):
419-
# Allow bullet lists
420-
out.append("\n" + (" " * indent) + line)
421-
elif indent > blockindent:
555+
if prev_blank_line_count <= 1:
556+
out.append("\n")
557+
out.append("**{}**\n".format(line.strip()))
558+
elif indent > blockindent and (arg_list or section_block):
422559
if arg_list and not literal_block and _RE_TYPED_ARGSTART.match(line):
423560
# start of new argument
424561
out.append(
425-
"\n"
426-
+ " " * blockindent
427-
+ " - "
562+
"- "
428563
+ _RE_TYPED_ARGSTART.sub(r"<b>`\1`</b> (\2): \3", line)
429564
)
430565
argindent = indent
431566
elif arg_list and not literal_block and _RE_ARGSTART.match(line):
432567
# start of an exception-type block
433568
out.append(
434-
"\n"
435-
+ " " * blockindent
436-
+ " - "
569+
"- "
437570
+ _RE_ARGSTART.sub(r"<b>`\1`</b>: \2", line)
438571
)
439572
argindent = indent
440573
elif indent > argindent:
441574
# attach docs text of argument
442575
# * (blockindent + 2)
443-
out.append(" " + line)
576+
padding = max(indent - argindent + offset, 0)
577+
out.append(" " * padding
578+
+ line.replace("\n",
579+
"\n" + " " * padding))
444580
else:
445-
out.append(line)
581+
padding = max(indent - blockindent + offset, 0)
582+
out.append(line.replace("\n",
583+
"\n" + " " * padding))
584+
elif line:
585+
padding = max(indent - blockindent + offset, 0)
586+
out.append(" " * padding
587+
+ line.replace("\n",
588+
"\n" + " " * padding))
446589
else:
447-
if line.strip() and literal_block:
448-
# indent has changed, if not empty line, break literal block
449-
line = "```\n" + line
450-
literal_block = False
451590
out.append(line)
452591

453-
if md_code_snippet:
454-
out.append("\n")
455-
elif not line and not quote_block:
456-
out.append("\n\n")
457-
elif not line and quote_block:
458-
out.append("\n>")
459-
else:
460-
out.append(" ")
592+
out.append("\n")
461593

462-
return "".join(out)
594+
if block_exit:
595+
block_exit = False
596+
if md_code_snippet:
597+
md_code_snippet = False
598+
if literal_block:
599+
literal_block = None
600+
elif doctest_block:
601+
doctest_block = None
463602

603+
if line.lstrip():
604+
prev_blank_line_count = 0
605+
else:
606+
prev_blank_line_count += 1
607+
return "".join(out)
464608

465609
class MarkdownGenerator(object):
466610
"""Markdown generator class."""

0 commit comments

Comments
 (0)