Skip to content

Commit e2e780d

Browse files
Configurable formatter (#171)
Co-authored-by: Bernát Gábor <[email protected]>
1 parent 91c9f86 commit e2e780d

File tree

5 files changed

+96
-29
lines changed

5 files changed

+96
-29
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Resolve type guard imports before evaluating annotations for objects
66
- Fix crash when the `inspect` module returns an invalid python syntax source
7+
- Made formatting function configurable using the option `typehints_formatter`
78

89
## 1.14.1
910

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ The following configuration options are accepted:
7272
None\]). If `False`, the \"Optional\"-type is kept. Note: If `False`, **any** Union containing `None` will be
7373
displayed as Optional! Note: If an optional parameter has only a single type (e.g Optional\[A\] or Union\[A, None\]),
7474
it will **always** be displayed as Optional!
75+
- `typehints_formatter` (default: `None`): If set to a function, this function will be called with `annotation` as first
76+
argument and `sphinx.config.Config` argument second. The function is expected to return a string with reStructuredText
77+
code or `None` to fall back to the default formatter.
7578

7679
## How it works
7780

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
import textwrap
77
import typing
88
from ast import FunctionDef, Module, stmt
9-
from functools import partial
10-
from typing import Any, AnyStr, NewType, TypeVar, get_type_hints
9+
from typing import Any, AnyStr, Callable, NewType, TypeVar, get_type_hints
1110

1211
from sphinx.application import Sphinx
12+
from sphinx.config import Config
1313
from sphinx.environment import BuildEnvironment
1414
from sphinx.ext.autodoc import Options
1515
from sphinx.util import logging
@@ -90,7 +90,13 @@ def get_annotation_args(annotation: Any, module: str, class_name: str) -> tuple[
9090
return getattr(annotation, "__args__", ())
9191

9292

93-
def format_annotation(annotation: Any, fully_qualified: bool = False, simplify_optional_unions: bool = True) -> str:
93+
def format_annotation(annotation: Any, config: Config) -> str:
94+
typehints_formatter: Callable[..., str] | None = getattr(config, "typehints_formatter", None)
95+
if typehints_formatter is not None:
96+
formatted = typehints_formatter(annotation, config)
97+
if formatted is not None:
98+
return formatted
99+
94100
# Special cases
95101
if annotation is None or annotation is type(None): # noqa: E721
96102
return ":py:obj:`None`"
@@ -116,6 +122,7 @@ def format_annotation(annotation: Any, fully_qualified: bool = False, simplify_o
116122
module = "typing"
117123

118124
full_name = f"{module}.{class_name}" if module != "builtins" else class_name
125+
fully_qualified: bool = getattr(config, "fully_qualified", False)
119126
prefix = "" if fully_qualified or full_name == class_name else "~"
120127
role = "data" if class_name in _PYDATA_ANNOTATIONS else "class"
121128
args_format = "\\[{}]"
@@ -131,20 +138,21 @@ def format_annotation(annotation: Any, fully_qualified: bool = False, simplify_o
131138
if len(args) == 2:
132139
full_name = "typing.Optional"
133140
args = tuple(x for x in args if x is not type(None)) # noqa: E721
134-
elif not simplify_optional_unions:
135-
full_name = "typing.Optional"
136-
args_format = f"\\[:py:data:`{prefix}typing.Union`\\[{{}}]]"
137-
args = tuple(x for x in args if x is not type(None)) # noqa: E721
141+
else:
142+
simplify_optional_unions: bool = getattr(config, "simplify_optional_unions", True)
143+
if not simplify_optional_unions:
144+
full_name = "typing.Optional"
145+
args_format = f"\\[:py:data:`{prefix}typing.Union`\\[{{}}]]"
146+
args = tuple(x for x in args if x is not type(None)) # noqa: E721
138147
elif full_name == "typing.Callable" and args and args[0] is not ...:
139-
fmt = ", ".join(format_annotation(arg, simplify_optional_unions=simplify_optional_unions) for arg in args[:-1])
140-
formatted_args = f"\\[\\[{fmt}]"
141-
formatted_args += f", {format_annotation(args[-1], simplify_optional_unions=simplify_optional_unions)}]"
148+
fmt = [format_annotation(arg, config) for arg in args]
149+
formatted_args = f"\\[\\[{', '.join(fmt[:-1])}], {fmt[-1]}]"
142150
elif full_name == "typing.Literal":
143151
formatted_args = f"\\[{', '.join(repr(arg) for arg in args)}]"
144152

145153
if args and not formatted_args:
146-
fmt = ", ".join(format_annotation(arg, fully_qualified, simplify_optional_unions) for arg in args)
147-
formatted_args = args_format.format(fmt)
154+
fmt = [format_annotation(arg, config) for arg in args]
155+
formatted_args = args_format.format(", ".join(fmt))
148156

149157
return f":py:{role}:`{prefix}{full_name}`{formatted_args}"
150158

@@ -438,11 +446,6 @@ def process_docstring(
438446
signature = None
439447
type_hints = get_all_type_hints(obj, name)
440448

441-
formatter = partial(
442-
format_annotation,
443-
fully_qualified=app.config.typehints_fully_qualified,
444-
simplify_optional_unions=app.config.simplify_optional_unions,
445-
)
446449
for arg_name, annotation in type_hints.items():
447450
if arg_name == "return":
448451
continue # this is handled separately later
@@ -453,7 +456,7 @@ def process_docstring(
453456
if arg_name.endswith("_"):
454457
arg_name = f"{arg_name[:-1]}\\_"
455458

456-
formatted_annotation = formatter(annotation)
459+
formatted_annotation = format_annotation(annotation, app.config)
457460

458461
search_for = {f":{field} {arg_name}:" for field in ("param", "parameter", "arg", "argument")}
459462
insert_index = None
@@ -480,7 +483,7 @@ def process_docstring(
480483
if "return" in type_hints and not inspect.isclass(original_obj):
481484
if what == "method" and name.endswith(".__init__"): # avoid adding a return type for data class __init__
482485
return
483-
formatted_annotation = formatter(type_hints["return"])
486+
formatted_annotation = format_annotation(type_hints["return"], app.config)
484487
insert_index = len(lines)
485488
for at, line in enumerate(lines):
486489
if line.startswith(":rtype:"):
@@ -506,6 +509,10 @@ def validate_config(app: Sphinx, env: BuildEnvironment, docnames: list[str]) ->
506509
if app.config.typehints_defaults not in valid | {False}:
507510
raise ValueError(f"typehints_defaults needs to be one of {valid!r}, not {app.config.typehints_defaults!r}")
508511

512+
formatter = app.config.typehints_formatter
513+
if formatter is not None and not callable(formatter):
514+
raise ValueError(f"typehints_formatter needs to be callable or `None`, not {formatter}")
515+
509516

510517
def setup(app: Sphinx) -> dict[str, bool]:
511518
app.add_config_value("set_type_checking_flag", False, "html")
@@ -514,6 +521,7 @@ def setup(app: Sphinx) -> dict[str, bool]:
514521
app.add_config_value("typehints_document_rtype", True, "env")
515522
app.add_config_value("typehints_defaults", None, "env")
516523
app.add_config_value("simplify_optional_unions", True, "env")
524+
app.add_config_value("typehints_formatter", None, "env")
517525
app.connect("builder-inited", builder_ready)
518526
app.connect("env-before-read-docs", validate_config) # config may be changed after “config-inited” event
519527
app.connect("autodoc-process-signature", process_signature)

tests/test_sphinx_autodoc_typehints.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,8 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
203203
],
204204
)
205205
def test_format_annotation(inv: Inventory, annotation: Any, expected_result: str) -> None:
206-
result = format_annotation(annotation)
206+
conf = create_autospec(Config)
207+
result = format_annotation(annotation, conf)
207208
assert result == expected_result
208209

209210
# Test with the "simplify_optional_unions" flag turned off:
@@ -214,21 +215,21 @@ def test_format_annotation(inv: Inventory, annotation: Any, expected_result: str
214215
# encapsulate Union in typing.Optional
215216
expected_result_not_simplified = ":py:data:`~typing.Optional`\\[" + expected_result_not_simplified
216217
expected_result_not_simplified += "]"
217-
assert format_annotation(annotation, simplify_optional_unions=False) == expected_result_not_simplified
218+
conf = create_autospec(Config, simplify_optional_unions=False)
219+
assert format_annotation(annotation, conf) == expected_result_not_simplified
218220

219221
# Test with the "fully_qualified" flag turned on
220222
if "typing" in expected_result_not_simplified:
221223
expected_result_not_simplified = expected_result_not_simplified.replace("~typing", "typing")
222-
assert (
223-
format_annotation(annotation, fully_qualified=True, simplify_optional_unions=False)
224-
== expected_result_not_simplified
225-
)
224+
conf = create_autospec(Config, fully_qualified=True, simplify_optional_unions=False)
225+
assert format_annotation(annotation, conf) == expected_result_not_simplified
226226

227227
# Test with the "fully_qualified" flag turned on
228228
if "typing" in expected_result or __name__ in expected_result:
229229
expected_result = expected_result.replace("~typing", "typing")
230230
expected_result = expected_result.replace("~" + __name__, __name__)
231-
assert format_annotation(annotation, fully_qualified=True) == expected_result
231+
conf = create_autospec(Config, fully_qualified=True)
232+
assert format_annotation(annotation, conf) == expected_result
232233

233234
# Test for the correct role (class vs data) using the official Sphinx inventory
234235
if "typing" in expected_result:
@@ -262,13 +263,15 @@ def test_format_annotation_both_libs(library: ModuleType, annotation: str, param
262263
return # pragma: no cover
263264

264265
ann = annotation_cls if params is None else annotation_cls[params]
265-
result = format_annotation(ann)
266+
result = format_annotation(ann, create_autospec(Config))
266267
assert result == expected_result
267268

268269

269270
def test_process_docstring_slot_wrapper() -> None:
270271
lines: list[str] = []
271-
config = create_autospec(Config, typehints_fully_qualified=False, simplify_optional_unions=False)
272+
config = create_autospec(
273+
Config, typehints_fully_qualified=False, simplify_optional_unions=False, typehints_formatter=None
274+
)
272275
app: Sphinx = create_autospec(Sphinx, config=config)
273276
process_docstring(app, "class", "SlotWrapper", Slotted, None, lines)
274277
assert not lines
@@ -679,6 +682,54 @@ def test_sphinx_output_defaults(
679682
assert text_contents == dedent(expected_contents)
680683

681684

685+
@pytest.mark.parametrize(
686+
("formatter_config_val", "expected"),
687+
[
688+
(None, ['("bool") -- foo', '("int") -- bar', '"str"']),
689+
(lambda ann, conf: "Test", ["(*Test*) -- foo", "(*Test*) -- bar", "Test"]),
690+
("some string", Exception("needs to be callable or `None`")),
691+
],
692+
)
693+
@pytest.mark.sphinx("text", testroot="dummy")
694+
@patch("sphinx.writers.text.MAXWIDTH", 2000)
695+
def test_sphinx_output_formatter(
696+
app: SphinxTestApp, status: StringIO, formatter_config_val: str, expected: tuple[str, ...] | Exception
697+
) -> None:
698+
set_python_path()
699+
700+
app.config.master_doc = "simple" # type: ignore # create flag
701+
app.config.typehints_formatter = formatter_config_val # type: ignore # create flag
702+
try:
703+
app.build()
704+
except Exception as e:
705+
if not isinstance(expected, Exception):
706+
raise
707+
assert str(expected) in str(e)
708+
return
709+
assert not isinstance(expected, Exception), "Expected app.build() to raise exception, but it didn’t"
710+
assert "build succeeded" in status.getvalue()
711+
712+
text_path = pathlib.Path(app.srcdir) / "_build" / "text" / "simple.txt"
713+
text_contents = text_path.read_text().replace("–", "--")
714+
expected_contents = f"""\
715+
Simple Module
716+
*************
717+
718+
dummy_module_simple.function(x, y=1)
719+
720+
Function docstring.
721+
722+
Parameters:
723+
* **x** {expected[0]}
724+
725+
* **y** {expected[1]}
726+
727+
Return type:
728+
{expected[2]}
729+
"""
730+
assert text_contents == dedent(expected_contents)
731+
732+
682733
def test_normalize_source_lines_async_def() -> None:
683734
source = """
684735
async def async_function():
@@ -733,7 +784,9 @@ def __init__(bound_args): # noqa: N805
733784

734785
@pytest.mark.parametrize("obj", [cmp_to_key, 1])
735786
def test_default_no_signature(obj: Any) -> None:
736-
config = create_autospec(Config, typehints_fully_qualified=False, simplify_optional_unions=False)
787+
config = create_autospec(
788+
Config, typehints_fully_qualified=False, simplify_optional_unions=False, typehints_formatter=None
789+
)
737790
app: Sphinx = create_autospec(Sphinx, config=config)
738791
lines: list[str] = []
739792
process_docstring(app, "what", "name", obj, None, lines)
@@ -749,6 +802,7 @@ def test_bound_class_method(method: FunctionType) -> None:
749802
typehints_document_rtype=False,
750803
always_document_param_types=True,
751804
typehints_defaults=True,
805+
typehints_formatter=None,
752806
)
753807
app: Sphinx = create_autospec(Sphinx, config=config)
754808
process_docstring(app, "class", method.__qualname__, method, None, [])

whitelist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ ast3
22
autodoc
33
autouse
44
backfill
5+
conf
56
contravariant
67
cpython
78
dedent

0 commit comments

Comments
 (0)