diff --git a/doc/source/intro.rst b/doc/source/intro.rst index e8e3f55..8f679e3 100644 --- a/doc/source/intro.rst +++ b/doc/source/intro.rst @@ -80,6 +80,7 @@ Example configuration file: "realign": true, "max_line_length": 72, "use_optional": false, + "convert_epytext_markup": "false", "remove_type_back_ticks": "true", "use_types": true, "separate_keywords": false @@ -251,6 +252,33 @@ back ticks around type definitions are removed. This option has 3 modes: - ``:py:class:`Test``` becomes ``Test`` - ``lot`s of `bool`s`` becomes ``lot`s of bools`` +convert\_epytext\_markup +'''''''''''''''''''''''' + +:: + + "convert_epytext_markup": "false" + +Convert epytext markup to reST syntax. Defaults to "false". If this is on, +epytext markup brackets in docstrings will be converted to reST syntax for +supported output formats (all except epytext). See `epytext markup`_. +This option has 3 modes: + +- ``"false"``: No conversion happens. +- ``"true"``: Epytext markup is converted in all text. For example: + + - ``I{text}`` becomes ``*text*`` + - ``B{text}`` stays as ``**text**`` + - ``C{source code}`` becomes ``` ``source code`` ``` + - ``M{m*x+b}`` becomes ``:math:`m*x+b``` + +- ``"types"``: All epytext markup is converted. In addition, in type strings, + we fully remove source code markup wrapping (``C{}``). For example: + + - ``I{text}`` becomes ``*text*`` + - ``B{text}`` stays as ``**text**`` + - ``C{MyType}`` becomes ``MyType`` + use\_types '''''''''' @@ -258,7 +286,7 @@ use\_types "use_types": true -Use types in argument output. Defaults to True. If False, argument, +Use types in variable output. Defaults to True. If False, argument, keyword-argument, attribute, and return type definitions will be skipped for output formats that support it (google and reST). This can be turned False for Python 3, where Sphinx recognizes type @@ -276,3 +304,4 @@ If set to False, all keyword-arguments are documented with the other arguments. .. _`type annotations`: https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#type-annotations +.. _`epytext markup`: http://epydoc.sourceforge.net/manual-epytext.html#basic-inline-markup diff --git a/example_config.json b/example_config.json index 47ad33e..5f4d59a 100644 --- a/example_config.json +++ b/example_config.json @@ -12,6 +12,7 @@ "realign": true, "max_line_length": 72, "use_optional": false, + "convert_epytext_markup": "false", "remove_type_back_ticks": "true", "use_types": true, "separate_keywords": false diff --git a/src/docconvert/configuration.py b/src/docconvert/configuration.py index ffc3d13..860e054 100644 --- a/src/docconvert/configuration.py +++ b/src/docconvert/configuration.py @@ -5,7 +5,7 @@ from . import parser from . import writer -from .writer.base import BackTickRemovalOption +from .writer.base import BackTickRemovalOption, EpytextMarkupConvertOption _LOGGER = logging.getLogger(__name__) @@ -25,6 +25,7 @@ "realign": True, "max_line_length": PEP8_MAX, "use_optional": False, + "convert_epytext_markup": EpytextMarkupConvertOption.FALSE, "remove_type_back_ticks": BackTickRemovalOption.TRUE, "use_types": True, "separate_keywords": False, diff --git a/src/docconvert/writer/base.py b/src/docconvert/writer/base.py index d36ad87..77f2db5 100644 --- a/src/docconvert/writer/base.py +++ b/src/docconvert/writer/base.py @@ -2,6 +2,7 @@ import abc import enum +import functools import re import textwrap @@ -18,8 +19,7 @@ class BaseWriter(object): This class is meant to be subclassed for each type of docstring. Attributes: - doc (Docstring): The docstring to - write. + doc (Docstring): The docstring to write. config (DocconvertConfiguration): The configuration options for conversion. output (list(str)): The generated output lines as a result of @@ -27,6 +27,7 @@ class BaseWriter(object): """ _directives = ("example", "note", "seealso", "warning", "reference", "todo") + _supports_epytext_convert = True def __init__(self, doc, indent, config, kwarg="", vararg=""): """ @@ -66,6 +67,28 @@ def __init__(self, doc, indent, config, kwarg="", vararg=""): self.config.output.max_line_length ) + # Setup regex for backtick removal if enabled + backtick_option = BackTickRemovalOption.from_bool_or_str( + self.config.output.remove_type_back_ticks + ) + self.backtick_regex = None + if backtick_option == BackTickRemovalOption.TRUE: + self.backtick_regex = re.compile(r"(?[^\s`]+)`") + elif backtick_option == BackTickRemovalOption.DIRECTIVES: + self.backtick_regex = re.compile(r"[^\s`]*`(?P[^\s`]+)`") + + # Setup regex for epytext markup convert if supported and enabled + self.epytext_markup_regex = None + self.remove_epytext_types = False + if self._supports_epytext_convert: + epytext_option = EpytextMarkupConvertOption.from_bool_or_str( + self.config.output.convert_epytext_markup + ) + if epytext_option != EpytextMarkupConvertOption.FALSE: + self.epytext_markup_regex = re.compile(r"([IBMC])\{(?P[^\}]*)\}") + if epytext_option == EpytextMarkupConvertOption.TYPES: + self.remove_epytext_types = True + def _calculate_max_line_length(self, max_length): """Calculates maximum line length for realigning. @@ -185,6 +208,7 @@ def write_raw(self, lines): for line in lines: # Append second line adjacent to quotes if first_line specified in config append = self._elements_written == 1 and self.config.output.first_line + line = self.convert_epytext_markup(line) self.write_line(line, append=append) def write_desc(self, desc, header=None, indent=1, hanging=True): @@ -201,6 +225,8 @@ def write_desc(self, desc, header=None, indent=1, hanging=True): hanging (bool): Whether or not lines under the first line have a hanging indent. """ + # Convert any lines before realigning so we are converting source + desc = [self.convert_epytext_markup(line) for line in desc] if header: if self._is_longer_than_max(header, indent): self.write_line(header, indent) @@ -325,22 +351,7 @@ def remove_back_ticks(self, text): """Removes back ticks from text. Removal depends on configuration of ``remove_type_back_ticks``. - See :py:class:`BackTickRemovalOption`. Option has three modes: - - - **FALSE**: No back ticks will be removed. - - **TRUE**: Back ticks will be removed, except from sphinx - directives. For example: - - - `` `list` of `str` `` becomes ``list of str`` - - `` :py:class:`Test` `` stays as `` :py:class:`Test` `` - - ``lot`s of `bool`s`` becomes ``lot`s of bools`` - - - **DIRECTIVES**: All back ticks, including directives, will be - removed. For example: - - - `` `list` of `str` `` becomes ``list of str`` - - `` :py:class:`Test` `` becomes ``Test`` - - ``lot`s of `bool`s`` becomes ``lot`s of bools`` + See :py:class:`BackTickRemovalOption`. Args: text (str): The text to remove back ticks from. @@ -348,18 +359,41 @@ def remove_back_ticks(self, text): Returns: str: The string with replaceable back ticks removed. """ - if not text: + if not text or not self.backtick_regex: return text - removal_option = BackTickRemovalOption.from_bool_or_str( - self.config.output.remove_type_back_ticks - ) - if removal_option == BackTickRemovalOption.FALSE: + return re.sub(self.backtick_regex, r"\g", text) + + def _replace_epytext_markup(self, match, in_type=False): + """Helper used in re.sub to replace epytext markup matches.""" + text = match.group(2) + if match.group(1) == "I": + return "*{}*".format(text) + elif match.group(1) == "B": + return "**{}**".format(text) + elif match.group(1) == "M": + return ":math:`{}`".format(text) + elif match.group(1) == "C": + if not in_type or not self.remove_epytext_types: + return "``{}``".format(text) + return text + + def convert_epytext_markup(self, text, in_type=False): + """Converts epytext markup syntax to reST syntax. + + Removal depends on configuration of ``convert_epytext_markup``. + See :py:class:`EpytextMarkupConvertOption`. + + Args: + text (str): The text to convert. + in_type (bool): Whether the text is in a type definition. + + Returns: + str: The string with markup converted. + """ + if not text or not self.epytext_markup_regex: return text - if removal_option == BackTickRemovalOption.TRUE: - replaceable_back_tick = re.compile(r"(?[^\s`]+)`") - else: - replaceable_back_tick = re.compile(r"[^\s`]*`(?P[^\s`]+)`") - return re.sub(replaceable_back_tick, r"\g", text) + replace = functools.partial(self._replace_epytext_markup, in_type=in_type) + return re.sub(self.epytext_markup_regex, replace, text) class BackTickRemovalOption(enum.Enum): @@ -405,5 +439,50 @@ def from_bool_or_str(cls, value): return cls(value) +class EpytextMarkupConvertOption(enum.Enum): + """Option for converting epytext inline markup in docstrings. + + Option has three modes: + + - **FALSE**: No epytext brackets will be converted + - **TRUE**: Epytext brackets will be converted to reST formats. + For example: + + - ``I{text}`` becomes ``*text*`` + - ``B{text}`` becomes ``**text**`` + - ``C{source code}`` becomes ``` ``source code`` ``` + - ``M{m*x+b}`` becomes ``:math:`m*x+b``` + + - **TYPES**: Epytext brackets will be converted to reST formats. + Source code markup will be completely removed from type strings. + For example: + + - ``I{text}`` becomes ``*text*`` + - ``B{text}`` becomes ``**text**`` + - ``C{source code}`` becomes ``source code`` + """ + + FALSE = "false" + TRUE = "true" + TYPES = "types" + + @classmethod + def from_bool_or_str(cls, value): + """Function to handle getting enum from boolean arguments. + + Args: + value (str or bool): The value of the option. Boolean values + will be converted to a string value. + + Returns: + EpytextBracketConvertOption: The removal option enum value. + """ + if value is True: + value = "true" + if value is False: + value = "false" + return cls(value) + + class InvalidDocstringElementError(Exception): """Custom exception for unrecognised docstring elements.""" diff --git a/src/docconvert/writer/google.py b/src/docconvert/writer/google.py index f26e0ba..0958d77 100644 --- a/src/docconvert/writer/google.py +++ b/src/docconvert/writer/google.py @@ -49,6 +49,7 @@ def write_directive(self, element): """ self.write_section_header(self._directive_title[element[0]]) for line in element[1]: + line = self.convert_epytext_markup(line) self.write_line(line, indent=1) def write_args(self, element): @@ -91,6 +92,7 @@ def write_var(self, var, use_optional=True): optional = "optional" kind = var.kind if self.config.output.use_types else "" kind = self.remove_back_ticks(kind) + kind = self.convert_epytext_markup(kind, in_type=True) kind = ", ".join(filter(None, (kind, optional))) if kind: kind = " ({0})".format(kind) @@ -120,6 +122,7 @@ def write_raises(self, element): self.write_section_header("Raises") for var in self.doc.raise_fields: kind = self.remove_back_ticks(var.kind) + kind = self.convert_epytext_markup(kind, in_type=True) if var.desc: header = "{0}:".format(kind) if kind else None self.write_desc(var.desc, header=header) @@ -134,6 +137,7 @@ def write_returns(self, element): """ kind = self.doc.return_field.kind if self.config.output.use_types else "" kind = self.remove_back_ticks(kind) + kind = self.convert_epytext_markup(kind, in_type=True) if not kind and not self.doc.return_field.desc: return diff --git a/src/docconvert/writer/numpy.py b/src/docconvert/writer/numpy.py index e704474..07f49c6 100644 --- a/src/docconvert/writer/numpy.py +++ b/src/docconvert/writer/numpy.py @@ -71,6 +71,7 @@ def write_directive(self, element): """ self.write_section_header(self._directive_title[element[0]]) for line in element[1]: + line = self.convert_epytext_markup(line) self.write_line(line) def write_var(self, var, use_optional=True): @@ -92,6 +93,7 @@ def write_var(self, var, use_optional=True): optional = "optional" kind = var.kind if self.config.output.use_types else "" kind = self.remove_back_ticks(kind) + kind = self.convert_epytext_markup(kind, in_type=True) kind = ", ".join(filter(None, (kind, optional))) if kind: kind = " : {0}".format(kind) @@ -110,6 +112,7 @@ def write_raises(self, element): self.write_section_header("Raises") for var in self.doc.raise_fields: kind = self.remove_back_ticks(var.kind) + kind = self.convert_epytext_markup(kind, in_type=True) if kind: self.write_line(kind) if var.desc: @@ -123,6 +126,7 @@ def write_returns(self, element): """ self.write_section_header("Returns") kind = self.remove_back_ticks(self.doc.return_field.kind) + kind = self.convert_epytext_markup(kind, in_type=True) # Return type is not optional for numpy docstrings if not kind: kind = "unknown" @@ -156,4 +160,5 @@ def write_raw(self, lines): for line in lines: # Append second line adjacent to quotes if first_line specified in config append = self._elements_written == 1 and self.config.output.first_line + line = self.convert_epytext_markup(line) self.write_line(line, append=append) diff --git a/src/docconvert/writer/rest.py b/src/docconvert/writer/rest.py index 79942a5..6b94504 100644 --- a/src/docconvert/writer/rest.py +++ b/src/docconvert/writer/rest.py @@ -34,6 +34,7 @@ def write_directive(self, element): if i == 0: self.write_desc([line], header=header, indent=0) else: + line = self.convert_epytext_markup(line) self.write_line(line, indent=1) def write_var(self, var, field, type_field="type", use_optional=True): @@ -51,6 +52,7 @@ def write_var(self, var, field, type_field="type", use_optional=True): optional = "optional" kind = var.kind if self.config.output.use_types else "" kind = self.remove_back_ticks(kind) + kind = self.convert_epytext_markup(kind, in_type=True) kind = ", ".join(filter(None, (kind, optional))) header = self._var_token.format(field, var.name) @@ -94,6 +96,7 @@ def write_raises(self, element): """ for var in self.doc.raise_fields: kind = self.remove_back_ticks(var.kind) + kind = self.convert_epytext_markup(kind, in_type=True) header = self._var_token.format("raises", kind) self.write_desc(var.desc, header=header, indent=0) @@ -105,6 +108,7 @@ def write_returns(self, element): """ kind = self.doc.return_field.kind if self.config.output.use_types else "" kind = self.remove_back_ticks(kind) + kind = self.convert_epytext_markup(kind, in_type=True) if self.doc.return_field.desc: header = self._field_token.format("returns") self.write_desc(self.doc.return_field.desc, header=header, indent=0) diff --git a/tests/test_base_writer.py b/tests/test_base_writer.py index 454cc48..3adb09f 100644 --- a/tests/test_base_writer.py +++ b/tests/test_base_writer.py @@ -251,3 +251,48 @@ def test_write_throws_invalid_element_for_unrecognized(self): writer = MyWriter(self.doc, "", self.config) with pytest.raises(docconvert.writer.base.InvalidDocstringElementError): writer.write() + + def test_remove_backticks(self): + self.config.output.remove_type_back_ticks = "true" + writer = MyWriter(self.doc, "", self.config) + assert writer.remove_back_ticks("`list` of `str`") == "list of str" + assert writer.remove_back_ticks("`lots` of bool`s") == "lots of bool`s" + assert writer.remove_back_ticks(":py:class:`Test`") == ":py:class:`Test`" + + self.config.output.remove_type_back_ticks = "directives" + writer = MyWriter(self.doc, "", self.config) + assert writer.remove_back_ticks(":py:class:`Test`") == "Test" + + def test_convert_epytext_markup(self): + self.config.output.convert_epytext_markup = "true" + writer = MyWriter(self.doc, "", self.config) + assert ( + writer.convert_epytext_markup("Testing I{epytext markup}") + == "Testing *epytext markup*" + ) + assert ( + writer.convert_epytext_markup("Testing B{epytext markup}") + == "Testing **epytext markup**" + ) + assert ( + writer.convert_epytext_markup("Testing M{epytext markup}") + == "Testing :math:`epytext markup`" + ) + assert ( + writer.convert_epytext_markup("Testing C{epytext markup}") + == "Testing ``epytext markup``" + ) + assert ( + writer.convert_epytext_markup("Testing C{epytext markup}", in_type=True) + == "Testing ``epytext markup``" + ) + + self.config.output.convert_epytext_markup = "types" + writer = MyWriter(self.doc, "", self.config) + assert ( + writer.convert_epytext_markup("Testing C{MyType}") == "Testing ``MyType``" + ) + assert ( + writer.convert_epytext_markup("Testing C{MyType}", in_type=True) + == "Testing MyType" + )