|
9 | 9 | import re |
10 | 10 | import subprocess |
11 | 11 | import types |
| 12 | +from dataclasses import dataclass |
12 | 13 | from pydoc import locate |
13 | 14 | from typing import Any, Callable, Dict, List, Optional |
14 | 15 |
|
15 | 16 | _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}$", |
17 | 18 | re.IGNORECASE, |
18 | 19 | ) |
19 | 20 |
|
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 | +) |
21 | 32 |
|
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) |
23 | 35 |
|
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) |
26 | 37 |
|
27 | 38 | _IGNORE_GENERATION_INSTRUCTION = "lazydocs: ignore" |
28 | 39 |
|
@@ -361,106 +372,239 @@ def _doc2md(obj: Any) -> str: |
361 | 372 | # doc = getdoc(func) or "" |
362 | 373 | doc = _get_docstring(obj) |
363 | 374 |
|
| 375 | + padding = 0 |
364 | 376 | blockindent = 0 |
365 | | - argindent = 1 |
| 377 | + argindent = 0 |
366 | 378 | out = [] |
367 | 379 | arg_list = False |
368 | | - literal_block = False |
| 380 | + section_block = False |
| 381 | + block_exit = False |
369 | 382 | 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 |
371 | 388 |
|
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 |
376 | 394 |
|
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. |
380 | 397 |
|
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. |
391 | 402 |
|
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. |
396 | 425 |
|
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. |
398 | 433 |
|
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 |
400 | 459 |
|
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) |
409 | 497 | 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) |
410 | 554 |
|
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): |
422 | 559 | if arg_list and not literal_block and _RE_TYPED_ARGSTART.match(line): |
423 | 560 | # start of new argument |
424 | 561 | out.append( |
425 | | - "\n" |
426 | | - + " " * blockindent |
427 | | - + " - " |
| 562 | + "- " |
428 | 563 | + _RE_TYPED_ARGSTART.sub(r"<b>`\1`</b> (\2): \3", line) |
429 | 564 | ) |
430 | 565 | argindent = indent |
431 | 566 | elif arg_list and not literal_block and _RE_ARGSTART.match(line): |
432 | 567 | # start of an exception-type block |
433 | 568 | out.append( |
434 | | - "\n" |
435 | | - + " " * blockindent |
436 | | - + " - " |
| 569 | + "- " |
437 | 570 | + _RE_ARGSTART.sub(r"<b>`\1`</b>: \2", line) |
438 | 571 | ) |
439 | 572 | argindent = indent |
440 | 573 | elif indent > argindent: |
441 | 574 | # attach docs text of argument |
442 | 575 | # * (blockindent + 2) |
443 | | - out.append(" " + line) |
| 576 | + padding = max(indent - argindent + offset, 0) |
| 577 | + out.append(" " * padding |
| 578 | + + line.replace("\n", |
| 579 | + "\n" + " " * padding)) |
444 | 580 | 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)) |
446 | 589 | 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 |
451 | 590 | out.append(line) |
452 | 591 |
|
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") |
461 | 593 |
|
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 |
463 | 602 |
|
| 603 | + if line.lstrip(): |
| 604 | + prev_blank_line_count = 0 |
| 605 | + else: |
| 606 | + prev_blank_line_count += 1 |
| 607 | + return "".join(out) |
464 | 608 |
|
465 | 609 | class MarkdownGenerator(object): |
466 | 610 | """Markdown generator class.""" |
|
0 commit comments