Skip to content

Commit f443c2e

Browse files
authored
Implement entry points to allow extension/customization (#41)
* Implement entry points to allow extension/customization Require Python 3.7 to use Protocol Fix test coverage * Bump version to 0.16
1 parent 377c2bb commit f443c2e

File tree

14 files changed

+309
-27
lines changed

14 files changed

+309
-27
lines changed

.github/workflows/tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,6 @@ jobs:
3535
run: |
3636
python -m build
3737
- name: Install package
38-
run: python -m pip install --find-links=dist --no-index --ignore-installed docstring_to_markdown
38+
run: python -m pip install --find-links=dist --ignore-installed docstring_to_markdown
3939
- name: Pip check
4040
run: python -m pip check

README.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
On the fly conversion of Python docstrings to markdown
88

9-
- Python 3.6+ (tested on 3.8 up to 3.13)
9+
- Python 3.7+ (tested on 3.8 up to 3.13)
1010
- can recognise reStructuredText and convert multiple of its features to Markdown
1111
- since v0.13 includes initial support for Google-formatted docstrings
1212

@@ -35,6 +35,11 @@ Traceback (most recent call last):
3535
docstring_to_markdown.UnknownFormatError
3636
```
3737

38+
### Extensibility
39+
40+
`docstring_to_markdown` entry point group allows to add custom converters which follow the `Converter` protocol.
41+
The built-in converters can be customized by providing entry point with matching name.
42+
3843
### Development
3944

4045
```bash

docstring_to_markdown/__init__.py

+47-15
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,59 @@
1-
from .cpython import cpython_to_markdown
2-
from .google import google_to_markdown, looks_like_google
3-
from .plain import looks_like_plain_text, plain_text_to_markdown
4-
from .rst import looks_like_rst, rst_to_markdown
1+
from importlib_metadata import entry_points
2+
from typing import List, TYPE_CHECKING
53

6-
__version__ = "0.15"
4+
from .types import Converter
5+
6+
if TYPE_CHECKING:
7+
from importlib_metadata import EntryPoint
8+
9+
__version__ = "0.16"
710

811

912
class UnknownFormatError(Exception):
1013
pass
1114

1215

13-
def convert(docstring: str) -> str:
14-
if looks_like_rst(docstring):
15-
return rst_to_markdown(docstring)
16+
def _entry_points_sort_key(entry_point: 'EntryPoint'):
17+
if entry_point.dist is None:
18+
return 1
19+
if entry_point.dist.name == "docstring-to-markdown":
20+
return 0
21+
return 1
22+
23+
24+
def _load_converters() -> List[Converter]:
25+
converter_entry_points = entry_points(
26+
group="docstring_to_markdown"
27+
)
28+
# sort so that the default ones can be overridden
29+
sorted_entry_points = sorted(
30+
converter_entry_points,
31+
key=_entry_points_sort_key
32+
)
33+
# de-duplicate
34+
unique_entry_points = {}
35+
for entry_point in sorted_entry_points:
36+
unique_entry_points[entry_point.name] = entry_point
1637

17-
if looks_like_google(docstring):
18-
return google_to_markdown(docstring)
38+
converters = []
39+
for entry_point in unique_entry_points.values():
40+
converter_class = entry_point.load()
41+
converters.append(converter_class())
1942

20-
if looks_like_plain_text(docstring):
21-
return plain_text_to_markdown(docstring)
43+
converters.sort(key=lambda converter: -converter.priority)
2244

23-
cpython = cpython_to_markdown(docstring)
24-
if cpython:
25-
return cpython
45+
return converters
46+
47+
48+
_CONVERTERS = None
49+
50+
51+
def convert(docstring: str) -> str:
52+
global _CONVERTERS
53+
if _CONVERTERS is None:
54+
_CONVERTERS = _load_converters()
55+
for converter in _CONVERTERS:
56+
if converter.can_convert(docstring):
57+
return converter.convert(docstring)
2658

2759
raise UnknownFormatError()

docstring_to_markdown/cpython.py

+24-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from typing import Union, List
22
from re import fullmatch
33

4+
from .types import Converter
45
from ._utils import escape_markdown
56

7+
68
def _is_cpython_signature_line(line: str) -> bool:
79
"""CPython uses signature lines in the following format:
810
@@ -30,8 +32,29 @@ def cpython_to_markdown(text: str) -> Union[str, None]:
3032
escape_markdown('\n'.join(other_lines))
3133
])
3234

35+
3336
def looks_like_cpython(text: str) -> bool:
3437
return cpython_to_markdown(text) is not None
3538

3639

37-
__all__ = ['looks_like_cpython', 'cpython_to_markdown']
40+
class CPythonConverter(Converter):
41+
42+
priority = 10
43+
44+
def __init__(self) -> None:
45+
self._last_docstring: Union[str, None] = None
46+
self._converted: Union[str, None] = None
47+
48+
def can_convert(self, docstring):
49+
self._last_docstring = docstring
50+
self._converted = cpython_to_markdown(docstring)
51+
return self._converted is not None
52+
53+
def convert(self, docstring):
54+
if docstring != self._last_docstring:
55+
self._last_docstring = docstring
56+
self._converted = cpython_to_markdown(docstring)
57+
return self._converted
58+
59+
60+
__all__ = ['looks_like_cpython', 'cpython_to_markdown', 'CPythonConverter']

docstring_to_markdown/google.py

+14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
from textwrap import dedent
33
from typing import List
44

5+
from .types import Converter
6+
7+
58
# All possible sections in Google style docstrings
69
SECTION_HEADERS: List[str] = [
710
"Args",
@@ -169,3 +172,14 @@ def google_to_markdown(text: str, extract_signature: bool = True) -> str:
169172
docstring = GoogleDocstring(text)
170173

171174
return docstring.as_markdown()
175+
176+
177+
class GoogleConverter(Converter):
178+
179+
priority = 75
180+
181+
def can_convert(self, docstring):
182+
return looks_like_google(docstring)
183+
184+
def convert(self, docstring):
185+
return google_to_markdown(docstring)

docstring_to_markdown/plain.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from re import fullmatch
2+
from .types import Converter
23
from ._utils import escape_markdown
34

45

@@ -24,4 +25,16 @@ def looks_like_plain_text(value: str) -> bool:
2425
def plain_text_to_markdown(text: str) -> str:
2526
return escape_markdown(text)
2627

27-
__all__ = ['looks_like_plain_text', 'plain_text_to_markdown']
28+
29+
class PlainTextConverter(Converter):
30+
31+
priority = 50
32+
33+
def can_convert(self, docstring):
34+
return looks_like_plain_text(docstring)
35+
36+
def convert(self, docstring):
37+
return plain_text_to_markdown(docstring)
38+
39+
40+
__all__ = ['looks_like_plain_text', 'plain_text_to_markdown', 'PlainTextConverter']

docstring_to_markdown/rst.py

+17-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typing import Callable, Match, Union, List, Dict
55
import re
66

7+
from .types import Converter
8+
79

810
class Directive:
911
def __init__(
@@ -532,7 +534,7 @@ def _consume_row(self, line: str):
532534
self._rows.append(self._split(line))
533535
self._expecting_row_content = not self._expecting_row_content
534536
else:
535-
self._state += 1
537+
self._state += 1 # pragma: no cover
536538

537539

538540
class BlockParser(IParser):
@@ -651,11 +653,13 @@ def initiate_parsing(self, line: str, current_language: str):
651653
if line.strip() == '.. autosummary::':
652654
language = ''
653655
line = ''
656+
suffix = ''
654657
else:
655658
line = re.sub(r'::$', '', line)
659+
suffix = '\n\n'
656660

657661
self._start_block(language)
658-
return IBlockBeginning(remainder=line.rstrip() + '\n\n')
662+
return IBlockBeginning(remainder=line.rstrip() + suffix)
659663

660664

661665
class MathBlockParser(IndentedBlockParser):
@@ -825,3 +829,14 @@ def flush_buffer():
825829
if active_parser:
826830
markdown += active_parser.finish_consumption(True)
827831
return markdown
832+
833+
834+
class ReStructuredTextConverter(Converter):
835+
836+
priority = 100
837+
838+
def can_convert(self, docstring):
839+
return looks_like_rst(docstring)
840+
841+
def convert(self, docstring):
842+
return rst_to_markdown(docstring)

docstring_to_markdown/types.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from typing_extensions import Protocol
2+
3+
4+
class Converter(Protocol):
5+
6+
def convert(self, docstring: str) -> str:
7+
"""Convert given docstring to markdown."""
8+
9+
def can_convert(self, docstring: str) -> bool:
10+
"""Check if conversion to markdown can be performed."""
11+
12+
# The higher the priority, the sooner the conversion
13+
# with this converter will be attempted.
14+
priority: int

setup.cfg

+17-2
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,36 @@ warn_unused_configs = True
2828

2929
[options]
3030
packages = find:
31-
python_requires = >=3.6
31+
python_requires = >=3.7
32+
install_requires =
33+
importlib-metadata>=3.6
34+
typing_extensions>=4.6
3235

3336
[options.package_data]
3437
docstring-to-markdown = py.typed
3538

39+
[options.entry_points]
40+
docstring_to_markdown =
41+
rst = docstring_to_markdown.rst:ReStructuredTextConverter
42+
google = docstring_to_markdown.google:GoogleConverter
43+
plain = docstring_to_markdown.plain:PlainTextConverter
44+
cpython = docstring_to_markdown.cpython:CPythonConverter
45+
3646
[tool:pytest]
3747
addopts =
3848
--pyargs tests
3949
--cov docstring_to_markdown
40-
--cov-fail-under=99
50+
--cov-fail-under=100
4151
--cov-report term-missing:skip-covered
4252
-p no:warnings
4353
--flake8
4454
-vv
4555

56+
[coverage:report]
57+
exclude_lines =
58+
pragma: no cover
59+
if TYPE_CHECKING:
60+
4661
[flake8]
4762
max-line-length = 120
4863
max-complexity = 15

tests/test_convert.py

+75
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1+
from contextlib import contextmanager
12
from docstring_to_markdown import convert, UnknownFormatError
3+
from docstring_to_markdown.types import Converter
4+
from docstring_to_markdown.cpython import CPythonConverter
5+
from importlib_metadata import EntryPoint, entry_points, distribution
6+
from unittest.mock import patch
7+
import docstring_to_markdown
28
import pytest
39

410
CPYTHON = """\
@@ -55,3 +61,72 @@ def test_convert_rst():
5561
def test_unknown_format():
5662
with pytest.raises(UnknownFormatError):
5763
convert('ARGS [arg1, arg2] RETURNS: str OR None')
64+
65+
66+
class HighPriorityConverter(Converter):
67+
priority = 120
68+
69+
def convert(self, docstring):
70+
return "HighPriority"
71+
72+
def can_convert(self, docstring):
73+
return True
74+
75+
76+
class MockEntryPoint(EntryPoint):
77+
def load(self):
78+
return self.value
79+
80+
dist = None
81+
82+
83+
class DistMockEntryPoint(MockEntryPoint):
84+
# Pretend it is contributed by `pytest`.
85+
# It could be anything else, but `pytest`
86+
# is guaranteed to be installed during tests.
87+
dist = distribution('pytest')
88+
89+
90+
class CustomCPythonConverter(CPythonConverter):
91+
priority = 10
92+
93+
def convert(self, docstring):
94+
return 'CustomCPython'
95+
96+
def can_convert(self, docstring):
97+
return True
98+
99+
100+
@contextmanager
101+
def custom_entry_points(entry_points):
102+
old = docstring_to_markdown._CONVERTERS
103+
docstring_to_markdown._CONVERTERS = None
104+
with patch.object(docstring_to_markdown, 'entry_points', return_value=entry_points):
105+
yield
106+
docstring_to_markdown._CONVERTERS = old
107+
108+
109+
def test_adding_entry_point():
110+
original_entry_points = entry_points(group="docstring_to_markdown")
111+
mock_entry_point = MockEntryPoint(
112+
name='high-priority-converter',
113+
group='docstring_to_markdown',
114+
value=HighPriorityConverter,
115+
)
116+
with custom_entry_points([*original_entry_points, mock_entry_point]):
117+
assert convert('test') == 'HighPriority'
118+
119+
120+
def test_replacing_entry_point():
121+
assert convert(CPYTHON) == CPYTHON_MD
122+
original_entry_points = entry_points(group="docstring_to_markdown")
123+
mock_entry_point = DistMockEntryPoint(
124+
name='cpython',
125+
group='docstring_to_markdown',
126+
value=CustomCPythonConverter
127+
)
128+
with custom_entry_points([*original_entry_points, mock_entry_point]):
129+
assert convert('test') == 'test'
130+
assert convert(GOOGLE) == GOOGLE_MD
131+
assert convert(RST) == RST_MD
132+
assert convert(CPYTHON) == 'CustomCPython'

tests/test_cpython.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import pytest
2-
from docstring_to_markdown.cpython import looks_like_cpython, cpython_to_markdown
2+
from docstring_to_markdown.cpython import looks_like_cpython, cpython_to_markdown, CPythonConverter
33

44
BOOL = """\
55
bool(x) -> bool
@@ -101,3 +101,10 @@ def test_conversion_bool():
101101

102102
def test_conversion_str():
103103
assert cpython_to_markdown(STR) == STR_MD
104+
105+
106+
def test_convert():
107+
converter = CPythonConverter()
108+
assert converter.can_convert(BOOL)
109+
assert not converter.can_convert('this is plain text')
110+
assert converter.convert(BOOL) == BOOL_MD

0 commit comments

Comments
 (0)