From 9475e522aaba6466d4741ad57953a75bb183c4c4 Mon Sep 17 00:00:00 2001 From: staticf0x Date: Tue, 10 Oct 2023 15:35:21 +0200 Subject: [PATCH 01/19] Basic support for Google style docstrings --- docstring_to_markdown/__init__.py | 5 ++ docstring_to_markdown/google.py | 137 ++++++++++++++++++++++++++++++ tests/test_google.py | 36 ++++++++ 3 files changed, 178 insertions(+) create mode 100644 docstring_to_markdown/google.py create mode 100644 tests/test_google.py diff --git a/docstring_to_markdown/__init__.py b/docstring_to_markdown/__init__.py index c81e124..520368f 100644 --- a/docstring_to_markdown/__init__.py +++ b/docstring_to_markdown/__init__.py @@ -1,3 +1,4 @@ +from .google import google_to_markdown, looks_like_google from .rst import looks_like_rst, rst_to_markdown __version__ = "0.12" @@ -10,4 +11,8 @@ class UnknownFormatError(Exception): def convert(docstring: str) -> str: if looks_like_rst(docstring): return rst_to_markdown(docstring) + + if looks_like_google(docstring): + return google_to_markdown(docstring) + raise UnknownFormatError() diff --git a/docstring_to_markdown/google.py b/docstring_to_markdown/google.py new file mode 100644 index 0000000..e3aee7d --- /dev/null +++ b/docstring_to_markdown/google.py @@ -0,0 +1,137 @@ +import re +from typing import Dict, List, Union + +_GOOGLE_SECTIONS: List[str] = [ + "Args", + "Returns", + "Raises", + "Yields", + "Example", + "Examples", + "Attributes", + "Note", +] + +ESCAPE_RULES = { + # Avoid Markdown in magic methods or filenames like __init__.py + r"__(?P\S+)__": r"\_\_\g\_\_", +} + + +class Section: + def __init__(self, name: str, content: str) -> None: + self.name = name + self.content = "" + + self._parse(content) + + def _parse(self, content: str) -> None: + content = content.rstrip("\n") + + parts = [] + cur_part = [] + + for line in content.split("\n"): + line = line.replace(" ", "", 1) + + if line.startswith(" "): + # Continuation from a multiline description + cur_part.append(line) + continue + + if cur_part: + # Leaving multiline description + parts.append(cur_part) + cur_part = [line] + else: + # Entering new description part + cur_part.append(line) + + # Last part + parts.append(cur_part) + + # Format section + for part in parts: + self.content += "- {}\n".format(part[0]) + + for line in part[1:]: + self.content += " {}\n".format(line) + + self.content = self.content.rstrip("\n") + + def as_markdown(self) -> str: + return "# {}\n\n{}\n\n".format(self.name, self.content) + + def __repr__(self) -> str: + return "Section(name={}, content={})".format(self.name, self.content) + + +class GoogleDocstring: + def __init__(self, docstring: str) -> None: + self.sections: list[Section] = [] + self.description: str = "" + + self._parse(docstring) + + def _parse(self, docstring: str) -> None: + self.sections = [] + self.description = "" + + buf = "" + cur_section = "" + + for line in docstring.split("\n"): + if is_section(line): + # Entering new section + if cur_section: + # Leaving previous section, save it and reset buffer + self.sections.append(Section(cur_section, buf)) + buf = "" + + # Remember currently parsed section + cur_section = line.rstrip(":") + continue + + # Parse section content + if cur_section: + buf += line + "\n" + else: + # Before setting cur_section, we're parsing the function description + self.description += line + "\n" + + # Last section + self.sections.append(Section(cur_section, buf)) + + def as_markdown(self) -> str: + text = self.description + + for section in self.sections: + text += section.as_markdown() + + return text.rstrip("\n") + "\n" # Only keep one last newline + + +def is_section(line: str) -> bool: + for section in _GOOGLE_SECTIONS: + if re.search(r"{}:".format(section), line): + return True + + return False + + +def looks_like_google(value: str) -> bool: + for section in _GOOGLE_SECTIONS: + if re.search(r"{}:\n".format(section), value): + return True + + return False + + +def google_to_markdown(text: str, extract_signature: bool = True) -> str: + # Escape parts we don't want to render + for pattern, replacement in ESCAPE_RULES.items(): + text = re.sub(pattern, replacement, text) + + docstring = GoogleDocstring(text) + + return docstring.as_markdown() diff --git a/tests/test_google.py b/tests/test_google.py new file mode 100644 index 0000000..521ecc7 --- /dev/null +++ b/tests/test_google.py @@ -0,0 +1,36 @@ +from docstring_to_markdown.google import google_to_markdown, looks_like_google + +BASIC_EXAMPLE = """Do **something**. + +Args: + a: some arg + b: some arg + +Returns: + Same *stuff* +""" + +BASIC_EXAMPLE_MD = """Do **something**. + +# Args + +- a: some arg +- b: some arg + +# Returns + +- Same *stuff* +""" + + +def test_looks_like_google_recognises_google(): + assert looks_like_google(BASIC_EXAMPLE) + + +def test_looks_like_google_ignores_plain_text(): + assert not looks_like_google("This is plain text") + assert not looks_like_google("See Also\n--------\n") + + +def test_google_to_markdown(): + assert google_to_markdown(BASIC_EXAMPLE) == BASIC_EXAMPLE_MD From 31c398506d7bb4465b923b75a32051e1217884c1 Mon Sep 17 00:00:00 2001 From: staticf0x Date: Wed, 11 Oct 2023 09:01:18 +0200 Subject: [PATCH 02/19] Parse individual arguments --- docstring_to_markdown/google.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docstring_to_markdown/google.py b/docstring_to_markdown/google.py index e3aee7d..911eb3c 100644 --- a/docstring_to_markdown/google.py +++ b/docstring_to_markdown/google.py @@ -52,7 +52,15 @@ def _parse(self, content: str) -> None: # Format section for part in parts: - self.content += "- {}\n".format(part[0]) + if ":" in part[0]: + spl = part[0].split(":") + + arg = spl[0] + description = ":".join(spl[1:]) + + self.content += "- `{}`: {}\n".format(arg, description) + else: + self.content += "- {}\n".format(part[0]) for line in part[1:]: self.content += " {}\n".format(line) @@ -60,7 +68,7 @@ def _parse(self, content: str) -> None: self.content = self.content.rstrip("\n") def as_markdown(self) -> str: - return "# {}\n\n{}\n\n".format(self.name, self.content) + return "#### {}\n\n{}\n\n".format(self.name, self.content) def __repr__(self) -> str: return "Section(name={}, content={})".format(self.name, self.content) From 4d8d55cefff9a95eb81d8d7ef7dbea6e72f9958f Mon Sep 17 00:00:00 2001 From: staticf0x Date: Wed, 11 Oct 2023 10:25:39 +0200 Subject: [PATCH 03/19] Fix indentation of arguments --- docstring_to_markdown/google.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docstring_to_markdown/google.py b/docstring_to_markdown/google.py index 911eb3c..43844b9 100644 --- a/docstring_to_markdown/google.py +++ b/docstring_to_markdown/google.py @@ -1,4 +1,5 @@ import re +from textwrap import dedent from typing import Dict, List, Union _GOOGLE_SECTIONS: List[str] = [ @@ -10,6 +11,7 @@ "Examples", "Attributes", "Note", + "Todo", ] ESCAPE_RULES = { @@ -52,18 +54,21 @@ def _parse(self, content: str) -> None: # Format section for part in parts: + indentation = "" + if ":" in part[0]: spl = part[0].split(":") arg = spl[0] - description = ":".join(spl[1:]) + description = ":".join(spl[1:]).lstrip() + indentation = (len(arg) + 6) * " " self.content += "- `{}`: {}\n".format(arg, description) else: self.content += "- {}\n".format(part[0]) for line in part[1:]: - self.content += " {}\n".format(line) + self.content += "{}{}\n".format(indentation, line.lstrip()) self.content = self.content.rstrip("\n") From 683d1cce5e0a2ae13d1cfed240ae0cdcbb833c87 Mon Sep 17 00:00:00 2001 From: staticf0x Date: Wed, 11 Oct 2023 10:28:07 +0200 Subject: [PATCH 04/19] Skip line parsing in some sections --- docstring_to_markdown/google.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docstring_to_markdown/google.py b/docstring_to_markdown/google.py index 43844b9..94994aa 100644 --- a/docstring_to_markdown/google.py +++ b/docstring_to_markdown/google.py @@ -2,7 +2,8 @@ from textwrap import dedent from typing import Dict, List, Union -_GOOGLE_SECTIONS: List[str] = [ +# All possible sections in Google style docstrings +SECTION_HEADERS: List[str] = [ "Args", "Returns", "Raises", @@ -14,6 +15,14 @@ "Todo", ] +# These sections will not be parsed as a list of arguments/return values/etc +PLAIN_TEXT_SECTIONS: List[str] = [ + "Examples", + "Example", + "Note", + "Todo", +] + ESCAPE_RULES = { # Avoid Markdown in magic methods or filenames like __init__.py r"__(?P\S+)__": r"\_\_\g\_\_", @@ -30,6 +39,10 @@ def __init__(self, name: str, content: str) -> None: def _parse(self, content: str) -> None: content = content.rstrip("\n") + if self.name in PLAIN_TEXT_SECTIONS: + self.content = dedent(content) + return + parts = [] cur_part = [] @@ -125,7 +138,7 @@ def as_markdown(self) -> str: def is_section(line: str) -> bool: - for section in _GOOGLE_SECTIONS: + for section in SECTION_HEADERS: if re.search(r"{}:".format(section), line): return True @@ -133,7 +146,7 @@ def is_section(line: str) -> bool: def looks_like_google(value: str) -> bool: - for section in _GOOGLE_SECTIONS: + for section in SECTION_HEADERS: if re.search(r"{}:\n".format(section), value): return True From 1cbb47358fa0ec30c2d4ff5ee9aeb7952eeafe86 Mon Sep 17 00:00:00 2001 From: staticf0x Date: Wed, 11 Oct 2023 10:49:13 +0200 Subject: [PATCH 05/19] Remove repr Only used it for debugging --- docstring_to_markdown/google.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/docstring_to_markdown/google.py b/docstring_to_markdown/google.py index 94994aa..6536616 100644 --- a/docstring_to_markdown/google.py +++ b/docstring_to_markdown/google.py @@ -88,8 +88,6 @@ def _parse(self, content: str) -> None: def as_markdown(self) -> str: return "#### {}\n\n{}\n\n".format(self.name, self.content) - def __repr__(self) -> str: - return "Section(name={}, content={})".format(self.name, self.content) class GoogleDocstring: From a2d9a88cff65be3bef5a9868396b3325944b0cf2 Mon Sep 17 00:00:00 2001 From: staticf0x Date: Wed, 11 Oct 2023 10:56:16 +0200 Subject: [PATCH 06/19] Add tests --- tests/test_google.py | 110 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 8 deletions(-) diff --git a/tests/test_google.py b/tests/test_google.py index 521ecc7..ab3c63d 100644 --- a/tests/test_google.py +++ b/tests/test_google.py @@ -1,7 +1,11 @@ +import pytest + from docstring_to_markdown.google import google_to_markdown, looks_like_google BASIC_EXAMPLE = """Do **something**. +Some more detailed description. + Args: a: some arg b: some arg @@ -12,19 +16,104 @@ BASIC_EXAMPLE_MD = """Do **something**. -# Args +Some more detailed description. + +#### Args -- a: some arg -- b: some arg +- `a`: some arg +- `b`: some arg -# Returns +#### Returns - Same *stuff* """ +ESCAPE_MAGIC_METHOD = """Example. + +Args: + a: see __init__.py +""" + +ESCAPE_MAGIC_METHOD_MD = """Example. + +#### Args + +- `a`: see \\_\\_init\\_\\_.py +""" + +PLAIN_SECTION = """Example. + +Args: + a: some arg + +Note: + Do not use this. + +Example: + Do it like this. +""" + +PLAIN_SECTION_MD = """Example. + +#### Args + +- `a`: some arg + +#### Note + +Do not use this. + +#### Example + +Do it like this. +""" + +MULTILINE_ARG_DESCRIPTION = """Example. + +Args: + a (str): This is a long description + spanning over several lines + also with broken indentation + b (str): Second arg +""" + +MULTILINE_ARG_DESCRIPTION_MD = """Example. + +#### Args + +- `a (str)`: This is a long description + spanning over several lines + also with broken indentation +- `b (str)`: Second arg +""" + +GOOGLE_CASES = { + "basic example": { + "google": BASIC_EXAMPLE, + "md": BASIC_EXAMPLE_MD, + }, + "escape magic method": { + "google": ESCAPE_MAGIC_METHOD, + "md": ESCAPE_MAGIC_METHOD_MD, + }, + "plain section": { + "google": PLAIN_SECTION, + "md": PLAIN_SECTION_MD, + }, + "multiline arg description": { + "google": MULTILINE_ARG_DESCRIPTION, + "md": MULTILINE_ARG_DESCRIPTION_MD, + }, +} + -def test_looks_like_google_recognises_google(): - assert looks_like_google(BASIC_EXAMPLE) +@pytest.mark.parametrize( + "google", + [case["google"] for case in GOOGLE_CASES.values()], + ids=GOOGLE_CASES.keys(), +) +def test_looks_like_google_recognises_google(google): + assert looks_like_google(google) def test_looks_like_google_ignores_plain_text(): @@ -32,5 +121,10 @@ def test_looks_like_google_ignores_plain_text(): assert not looks_like_google("See Also\n--------\n") -def test_google_to_markdown(): - assert google_to_markdown(BASIC_EXAMPLE) == BASIC_EXAMPLE_MD +@pytest.mark.parametrize( + "google,markdown", + [[case["google"], case["md"]] for case in GOOGLE_CASES.values()], + ids=GOOGLE_CASES.keys(), +) +def test_google_to_markdown(google, markdown): + assert google_to_markdown(google) == markdown From 2318f9aec23aeb7a6ab89ea9b2b831c6cdf6b89c Mon Sep 17 00:00:00 2001 From: staticf0x Date: Wed, 11 Oct 2023 11:49:23 +0200 Subject: [PATCH 07/19] Support indented arg descriptions --- docstring_to_markdown/google.py | 18 ++++++++++++++---- tests/test_google.py | 5 +++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/docstring_to_markdown/google.py b/docstring_to_markdown/google.py index 6536616..ad07226 100644 --- a/docstring_to_markdown/google.py +++ b/docstring_to_markdown/google.py @@ -1,6 +1,6 @@ import re from textwrap import dedent -from typing import Dict, List, Union +from typing import List # All possible sections in Google style docstrings SECTION_HEADERS: List[str] = [ @@ -68,6 +68,7 @@ def _parse(self, content: str) -> None: # Format section for part in parts: indentation = "" + skip_first = False if ":" in part[0]: spl = part[0].split(":") @@ -76,11 +77,21 @@ def _parse(self, content: str) -> None: description = ":".join(spl[1:]).lstrip() indentation = (len(arg) + 6) * " " - self.content += "- `{}`: {}\n".format(arg, description) + if description: + self.content += "- `{}`: {}\n".format(arg, description) + else: + skip_first = True + self.content += "- `{}`: ".format(arg) else: self.content += "- {}\n".format(part[0]) - for line in part[1:]: + for n, line in enumerate(part[1:]): + if skip_first and n == 0: + # This ensures that indented args get moved to the + # previous line + self.content += "{}\n".format(line.lstrip()) + continue + self.content += "{}{}\n".format(indentation, line.lstrip()) self.content = self.content.rstrip("\n") @@ -89,7 +100,6 @@ def as_markdown(self) -> str: return "#### {}\n\n{}\n\n".format(self.name, self.content) - class GoogleDocstring: def __init__(self, docstring: str) -> None: self.sections: list[Section] = [] diff --git a/tests/test_google.py b/tests/test_google.py index ab3c63d..78486bc 100644 --- a/tests/test_google.py +++ b/tests/test_google.py @@ -75,6 +75,9 @@ spanning over several lines also with broken indentation b (str): Second arg + c (str): + On the next line + And also multiple lines """ MULTILINE_ARG_DESCRIPTION_MD = """Example. @@ -85,6 +88,8 @@ spanning over several lines also with broken indentation - `b (str)`: Second arg +- `c (str)`: On the next line + And also multiple lines """ GOOGLE_CASES = { From 33129fc219a4c2c9b3a9537eb9c50d93c7cb1804 Mon Sep 17 00:00:00 2001 From: staticf0x Date: Wed, 11 Oct 2023 13:35:29 +0200 Subject: [PATCH 08/19] Fix typing compatibility --- docstring_to_markdown/google.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docstring_to_markdown/google.py b/docstring_to_markdown/google.py index ad07226..e3ca5a8 100644 --- a/docstring_to_markdown/google.py +++ b/docstring_to_markdown/google.py @@ -102,7 +102,7 @@ def as_markdown(self) -> str: class GoogleDocstring: def __init__(self, docstring: str) -> None: - self.sections: list[Section] = [] + self.sections: List[Section] = [] self.description: str = "" self._parse(docstring) From ea693e05a4dd9b3978f329e09e6e14037435e2a9 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Wed, 11 Oct 2023 19:13:12 +0100 Subject: [PATCH 09/19] Prepare release v0.13 --- docstring_to_markdown/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docstring_to_markdown/__init__.py b/docstring_to_markdown/__init__.py index 520368f..b4d0bdf 100644 --- a/docstring_to_markdown/__init__.py +++ b/docstring_to_markdown/__init__.py @@ -1,7 +1,7 @@ from .google import google_to_markdown, looks_like_google from .rst import looks_like_rst, rst_to_markdown -__version__ = "0.12" +__version__ = "0.13" class UnknownFormatError(Exception): From cab53d55d8d1dfe5cdb5314a9658508970413efd Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Wed, 11 Oct 2023 19:15:51 +0100 Subject: [PATCH 10/19] Update README with Google docstring note --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2f09ef6..6cea177 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ On the fly conversion of Python docstrings to markdown - Python 3.6+ -- currently can recognise reStructuredText and convert multiple of its features to Markdown -- in the future will be able to convert Google docstrings too +- can recognise reStructuredText and convert multiple of its features to Markdown +- since v0.13 includes initial support for Google-formatted docstrings ### Installation @@ -16,7 +16,6 @@ On the fly conversion of Python docstrings to markdown pip install docstring-to-markdown ``` - ### Example Convert reStructuredText: From dbc549477d1eda6ef4cd0058d2de8dbf507a1536 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:22:34 +0000 Subject: [PATCH 11/19] Add plain text and cPython docs support --- docstring_to_markdown/__init__.py | 9 +++ docstring_to_markdown/_utils.py | 5 ++ docstring_to_markdown/cpython.py | 37 +++++++++++ docstring_to_markdown/plain.py | 27 ++++++++ setup.cfg | 2 +- tests/test_convert.py | 57 +++++++++++++++++ tests/test_cpython.py | 103 ++++++++++++++++++++++++++++++ tests/test_plain.py | 42 ++++++++++++ 8 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 docstring_to_markdown/_utils.py create mode 100644 docstring_to_markdown/cpython.py create mode 100644 docstring_to_markdown/plain.py create mode 100644 tests/test_convert.py create mode 100644 tests/test_cpython.py create mode 100644 tests/test_plain.py diff --git a/docstring_to_markdown/__init__.py b/docstring_to_markdown/__init__.py index b4d0bdf..f778d47 100644 --- a/docstring_to_markdown/__init__.py +++ b/docstring_to_markdown/__init__.py @@ -1,4 +1,6 @@ +from .cpython import cpython_to_markdown from .google import google_to_markdown, looks_like_google +from .plain import looks_like_plain_text, plain_text_to_markdown from .rst import looks_like_rst, rst_to_markdown __version__ = "0.13" @@ -15,4 +17,11 @@ def convert(docstring: str) -> str: if looks_like_google(docstring): return google_to_markdown(docstring) + if looks_like_plain_text(docstring): + return plain_text_to_markdown(docstring) + + cpython = cpython_to_markdown(docstring) + if cpython: + return cpython + raise UnknownFormatError() diff --git a/docstring_to_markdown/_utils.py b/docstring_to_markdown/_utils.py new file mode 100644 index 0000000..847c699 --- /dev/null +++ b/docstring_to_markdown/_utils.py @@ -0,0 +1,5 @@ +from re import sub + + +def escape_markdown(text: str) -> str: + return sub(r'([\\#*_[\]])', r'\\\1', text) diff --git a/docstring_to_markdown/cpython.py b/docstring_to_markdown/cpython.py new file mode 100644 index 0000000..e2cae78 --- /dev/null +++ b/docstring_to_markdown/cpython.py @@ -0,0 +1,37 @@ +from typing import Union, List +from re import fullmatch + +from ._utils import escape_markdown + +def _is_cpython_signature_line(line: str) -> bool: + """CPython uses signature lines in the following format: + + str(bytes_or_buffer[, encoding[, errors]]) -> str + """ + return fullmatch(r'\w+\(\S*(, \S+)*(\[, \S+\])*\)\s--?>\s.+', line) is not None + + +def cpython_to_markdown(text: str) -> Union[str, None]: + signature_lines: List[str] = [] + other_lines: List[str] = [] + for line in text.splitlines(): + if not other_lines and _is_cpython_signature_line(line): + signature_lines.append(line) + elif not signature_lines: + return None + elif line.startswith(' '): + signature_lines.append(line) + else: + other_lines.append(line) + return '\n'.join([ + '```', + '\n'.join(signature_lines), + '```', + escape_markdown('\n'.join(other_lines)) + ]) + +def looks_like_cpython(text: str) -> bool: + return cpython_to_markdown(text) is not None + + +__all__ = ['looks_like_cpython', 'cpython_to_markdown'] diff --git a/docstring_to_markdown/plain.py b/docstring_to_markdown/plain.py new file mode 100644 index 0000000..d3bf7fb --- /dev/null +++ b/docstring_to_markdown/plain.py @@ -0,0 +1,27 @@ +from re import fullmatch +from ._utils import escape_markdown + + +def looks_like_plain_text(value: str) -> bool: + """Check if given string has plain text following English syntax without need for escaping. + + Accepts: + - words without numbers + - full stop, bangs and question marks at the end of a word if followed by a space or end of string + - commas, colons and semicolons if after a word and followed by a space + - dashes between words (like in `e-mail`) + - double and single quotes if proceeded with a space and followed by a word, or if proceeded by a word and followed by a space (or end of string); single quotes are also allowed in between two words + - parentheses if opening preceded by space and closing followed by space or end + + Does not accept: + - square brackets (used in markdown a lot) + """ + if '_' in value: + return False + return fullmatch(r"((\w[\.!\?\)'\"](\s|$))|(\w[,:;]\s)|(\w[-']\w)|(\w\s['\"\(])|\w|\s)+", value) is not None + + +def plain_text_to_markdown(text: str) -> str: + return escape_markdown(text) + +__all__ = ['looks_like_plain_text', 'plain_text_to_markdown'] diff --git a/setup.cfg b/setup.cfg index 4b48e64..0bb013f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ docstring-to-markdown = py.typed addopts = --pyargs tests --cov docstring_to_markdown - --cov-fail-under=98 + --cov-fail-under=99 --cov-report term-missing:skip-covered -p no:warnings --flake8 diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 0000000..7642aeb --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,57 @@ +from docstring_to_markdown import convert, UnknownFormatError +import pytest + +CPYTHON = """\ +bool(x) -> bool + +Returns True when the argument x is true, False otherwise.\ +""" + + +CPYTHON_MD = """\ +``` +bool(x) -> bool +``` + +Returns True when the argument x is true, False otherwise.\ +""" + +GOOGLE = """Do **something**. + +Args: + a: some arg + b: some arg +""" + +GOOGLE_MD = """Do **something**. + +#### Args + +- `a`: some arg +- `b`: some arg +""" + + +RST = "Please see `this link`__." +RST_MD = "Please see [this link](https://example.com)." + + +def test_convert_cpython(): + assert convert(CPYTHON) == CPYTHON_MD + + +def test_convert_plain_text(): + assert convert('This is a sentence.') == 'This is a sentence.' + + +def test_convert_google(): + assert convert(GOOGLE) == GOOGLE_MD + + +def test_convert_rst(): + assert convert(RST) == RST_MD + + +def test_unknown_format(): + with pytest.raises(UnknownFormatError): + convert('ARGS [arg1, arg2] RETURNS: str OR None') diff --git a/tests/test_cpython.py b/tests/test_cpython.py new file mode 100644 index 0000000..2b7245d --- /dev/null +++ b/tests/test_cpython.py @@ -0,0 +1,103 @@ +import pytest +from docstring_to_markdown.cpython import looks_like_cpython, cpython_to_markdown + +BOOL = """\ +bool(x) -> bool + +Returns True when the argument x is true, False otherwise.\ +""" + +BOOL_MD = """\ +``` +bool(x) -> bool +``` + +Returns True when the argument x is true, False otherwise.\ +""" + +BYTES = """\ +bytes(iterable_of_ints) -> bytes +bytes(string, encoding[, errors]) -> bytes +bytes(bytes_or_buffer) -> immutable copy of bytes_or_buffer +bytes(int) -> bytes object of size given by the parameter initialized with null bytes +bytes() -> empty bytes object + +Construct an immutable array of bytes from: + - an iterable yielding integers in range(256) + - a text string encoded using the specified encoding + - any object implementing the buffer API. + - an integer\ +""" + +COLLECTIONS_DEQUEUE = """\ +deque([iterable[, maxlen]]) --> deque object + +A list-like sequence optimized for data accesses near its endpoints.\ +""" + +DICT = """\ +dict() -> new empty dictionary +dict(mapping) -> new dictionary initialized from a mapping object's + (key, value) pairs +dict(iterable) -> new dictionary initialized as if via: + d = {} + for k, v in iterable: + d[k] = v +dict(**kwargs) -> new dictionary initialized with the name=value pairs + in the keyword argument list. For example: dict(one=1, two=2)\ +""" + +STR = """\ +str(object='') -> str +str(bytes_or_buffer[, encoding[, errors]]) -> str + +Create a new string object from the given object. If encoding or +errors is specified, then the object must expose a data buffer +that will be decoded using the given encoding and error handler. +Otherwise, returns the result of object.__str__() (if defined) +or repr(object).\ +""" + +STR_MD = """\ +``` +str(object='') -> str +str(bytes_or_buffer[, encoding[, errors]]) -> str +``` + +Create a new string object from the given object. If encoding or +errors is specified, then the object must expose a data buffer +that will be decoded using the given encoding and error handler. +Otherwise, returns the result of object.\\_\\_str\\_\\_() (if defined) +or repr(object).\ +""" + + +@pytest.mark.parametrize("text", [BYTES, STR, DICT, BOOL, COLLECTIONS_DEQUEUE]) +def test_accepts_cpython_docstrings(text): + assert looks_like_cpython(text) is True + + +@pytest.mark.parametrize("text", [ + "[link label](https://link)", + "![image label](https://source)", + "Some **bold** text", + "More __bold__ text", + "Some *italic* text", + "More _italic_ text", + "This is a sentence.", + "Exclamation!", + "Can I ask a question?", + "Let's send an e-mail", + "Parentheses (are) fine (really)", + "Double \"quotes\" and single 'quotes'" +]) +def test_rejects_markdown_and_plain_text(text): + assert looks_like_cpython(text) is False + + +def test_conversion_bool(): + assert cpython_to_markdown(BOOL) == BOOL_MD + + +def test_conversion_str(): + assert cpython_to_markdown(STR) == STR_MD diff --git a/tests/test_plain.py b/tests/test_plain.py new file mode 100644 index 0000000..61ef4b4 --- /dev/null +++ b/tests/test_plain.py @@ -0,0 +1,42 @@ +import pytest +from docstring_to_markdown.plain import looks_like_plain_text, plain_text_to_markdown + + +@pytest.mark.parametrize("text", [ + "This is a sentence.", + "Exclamation!", + "Can I ask a question?", + "Let's send an e-mail", + "Parentheses (are) fine (really)", + "Double \"quotes\" and single 'quotes'" +]) +def test_accepts_english(text): + assert looks_like_plain_text(text) is True + + +@pytest.mark.parametrize("text", [ + "[link label](https://link)", + "![image label](https://source)", + "Some **bold** text", + "More __bold__ text", + "Some *italic* text", + "More _italic_ text" +]) +def test_rejects_markdown(text): + assert looks_like_plain_text(text) is False + + +@pytest.mark.parametrize("text", [ + "def test():", + "print(123)", + "func(arg)", + "2 + 2", + "var['test']", + "x = 'test'" +]) +def test_rejects_code(text): + assert looks_like_plain_text(text) is False + + +def test_conversion(): + assert plain_text_to_markdown("test") == "test" From 08286b6692b6376572ee01288d7ba7a6064ed88f Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:26:25 +0000 Subject: [PATCH 12/19] Bump version to v0.14 --- docstring_to_markdown/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docstring_to_markdown/__init__.py b/docstring_to_markdown/__init__.py index f778d47..317c724 100644 --- a/docstring_to_markdown/__init__.py +++ b/docstring_to_markdown/__init__.py @@ -3,7 +3,7 @@ from .plain import looks_like_plain_text, plain_text_to_markdown from .rst import looks_like_rst, rst_to_markdown -__version__ = "0.13" +__version__ = "0.14" class UnknownFormatError(Exception): From 5c36ac7bfb45bb660d01c71a61da6279628141c4 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Wed, 21 Feb 2024 13:33:47 +0000 Subject: [PATCH 13/19] Fix multi-line links and incorrect dunder escapes in code --- docstring_to_markdown/__init__.py | 2 +- docstring_to_markdown/rst.py | 8 ++++---- tests/test_rst.py | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docstring_to_markdown/__init__.py b/docstring_to_markdown/__init__.py index 317c724..62fa804 100644 --- a/docstring_to_markdown/__init__.py +++ b/docstring_to_markdown/__init__.py @@ -3,7 +3,7 @@ from .plain import looks_like_plain_text, plain_text_to_markdown from .rst import looks_like_rst, rst_to_markdown -__version__ = "0.14" +__version__ = "0.15" class UnknownFormatError(Exception): diff --git a/docstring_to_markdown/rst.py b/docstring_to_markdown/rst.py index 174f9de..ba50d91 100644 --- a/docstring_to_markdown/rst.py +++ b/docstring_to_markdown/rst.py @@ -1,13 +1,13 @@ from abc import ABC, abstractmethod from enum import IntEnum, auto from types import SimpleNamespace -from typing import Union, List, Dict +from typing import Callable, Match, Union, List, Dict import re class Directive: def __init__( - self, pattern: str, replacement: str, + self, pattern: str, replacement: Union[str, Callable[[Match], str]], name: Union[str, None] = None, flags: int = 0 ): @@ -249,7 +249,7 @@ def inline_markdown(self): ), Directive( pattern=r'`(?P