Skip to content

Commit

Permalink
Use formatter option (#44)
Browse files Browse the repository at this point in the history
  • Loading branch information
flying-sheep authored Jan 10, 2022
1 parent ddd3631 commit 0051bf4
Show file tree
Hide file tree
Showing 6 changed files with 53 additions and 93 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ doc = [
'scanpydoc[typehints]',
'sphinx-rtd-theme',
]
typehints = ['sphinx-autodoc-typehints>=1.14']
typehints = ['sphinx-autodoc-typehints>=1.15.1']
theme = ['sphinx-rtd-theme']

[tool.flit.entrypoints.'sphinx.html_themes']
Expand Down
3 changes: 1 addition & 2 deletions scanpydoc/elegant_typehints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ def x() -> Tuple[int, float]:
from pathlib import Path
from typing import Any, Dict

import sphinx_autodoc_typehints
from docutils.parsers.rst import roles
from sphinx.application import Sphinx
from sphinx.config import Config
Expand Down Expand Up @@ -102,7 +101,7 @@ def setup(app: Sphinx) -> Dict[str, Any]:

from .formatting import _role_annot, format_annotation

sphinx_autodoc_typehints.format_annotation = format_annotation
app.config.typehints_formatter = format_annotation
for name in ["annotation-terse", "annotation-full"]:
roles.register_canonical_role(
name, partial(_role_annot, additional_classes=name.split("-"))
Expand Down
68 changes: 20 additions & 48 deletions scanpydoc/elegant_typehints/formatting.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import collections.abc as cabc
import inspect
from functools import partial
from typing import Any, Dict, Iterable, List, Sequence, Tuple, Type, Union
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type, Union

from sphinx.config import Config


try: # 3.8 additions
from typing import Literal, get_args, get_origin
except ImportError:
from typing_extensions import Literal, get_args, get_origin

import sphinx_autodoc_typehints
from docutils import nodes
from docutils.nodes import Node
from docutils.parsers.rst.roles import set_classes
Expand All @@ -20,20 +21,16 @@
from scanpydoc import elegant_typehints


def _format_full(
annotation: Type[Any],
fully_qualified: bool = False,
simplify_optional_unions: bool = True,
):
def _format_full(annotation: Type[Any], config: Config) -> Optional[str]:
if inspect.isclass(annotation) and annotation.__module__ == "builtins":
return _format_orig(annotation, fully_qualified, simplify_optional_unions)
return None

origin = get_origin(annotation)
tilde = "" if fully_qualified else "~"
tilde = "" if config.typehints_fully_qualified else "~"

annotation_cls = annotation if inspect.isclass(annotation) else type(annotation)
if annotation_cls.__module__ == "typing":
return _format_orig(annotation, fully_qualified, simplify_optional_unions)
return None

# Only if this is a real class we override sphinx_autodoc_typehints
if inspect.isclass(annotation) or inspect.isclass(origin):
Expand All @@ -43,22 +40,14 @@ def _format_full(
if override is not None:
return f":py:{role}:`{tilde}{override}`"

return _format_orig(annotation, fully_qualified, simplify_optional_unions)
return None


def _format_terse(
annotation: Type[Any],
fully_qualified: bool = False,
simplify_optional_unions: bool = True,
) -> str:
def _format_terse(annotation: Type[Any], config: Config) -> str:
origin = get_origin(annotation)
args = get_args(annotation)
tilde = "" if fully_qualified else "~"
fmt = partial(
_format_terse,
fully_qualified=fully_qualified,
simplify_optional_unions=simplify_optional_unions,
)
tilde = "" if config.typehints_fully_qualified else "~"
fmt = partial(_format_terse, config=config)

# display `Union[A, B]` as `A | B`
if origin is Union:
Expand All @@ -85,51 +74,34 @@ def _format_terse(
if origin is Literal:
return f"{{{', '.join(map(repr, args))}}}"

return _format_full(annotation, fully_qualified, simplify_optional_unions)
return _format_full(annotation, config) or _format_orig(annotation, config)


def format_annotation(
annotation: Type[Any],
fully_qualified: bool = False,
simplify_optional_unions: bool = True,
) -> str:
def format_annotation(annotation: Type[Any], config: Config) -> Optional[str]:
r"""Generate reStructuredText containing links to the types.
Unlike :func:`sphinx_autodoc_typehints.format_annotation`,
it tries to achieve a simpler style as seen in numeric packages like numpy.
Args:
annotation: A type or class used as type annotation.
fully_qualified: If links should be formatted as fully qualified
(e.g. ``:py:class:`foo.Bar```) or not (e.g. ``:py:class:`~foo.Bar```).
simplify_optional_unions: If Unions should be minimized if they contain
3 or more elements one of which is ``None``. (If ``True``, e.g.
``Optional[Union[str, int]]`` becomes ``Union[str, int, None]``)
config: Sphinx config containing ``sphinx-autodoc-typehints``’s options.
Returns:
reStructuredText describing the type
"""
if sphinx_autodoc_typehints.format_annotation is not format_annotation:
raise RuntimeError(
"This function is not guaranteed to work correctly without overriding"
"`sphinx_autodoc_typehints.format_annotation` with it."
)

curframe = inspect.currentframe()
calframe = inspect.getouterframes(curframe, 2)
if calframe[1][3] == "process_docstring":
return format_both(annotation, fully_qualified, simplify_optional_unions)
if calframe[2].function == "process_docstring":
return format_both(annotation, config)
else: # recursive use
return _format_full(annotation, fully_qualified, simplify_optional_unions)
return _format_full(annotation, config)


def format_both(
annotation: Type[Any],
fully_qualified: bool = False,
simplify_optional_unions: bool = True,
) -> str:
terse = _format_terse(annotation, fully_qualified, simplify_optional_unions)
full = _format_full(annotation, fully_qualified, simplify_optional_unions)
def format_both(annotation: Type[Any], config: Config) -> str:
terse = _format_terse(annotation, config)
full = _format_full(annotation, config) or _format_orig(annotation, config)
return f":annotation-terse:`{_escape(terse)}`\\ :annotation-full:`{_escape(full)}`"


Expand Down
2 changes: 1 addition & 1 deletion scanpydoc/elegant_typehints/return_tuple.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def process_docstring(

if len(idxs_ret_names) == len(ret_types):
for l, rt in zip(idxs_ret_names, ret_types):
typ = format_both(rt, app.config.typehints_fully_qualified)
typ = format_both(rt, app.config)
lines[l : l + 1] = [f"{lines[l]} : {typ}"]


Expand Down
11 changes: 9 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import importlib.util
import linecache
import sys
import typing as t
from tempfile import NamedTemporaryFile
from textwrap import dedent
from uuid import uuid4

import pytest
from docutils.nodes import document
Expand Down Expand Up @@ -55,14 +57,19 @@ def _render(app: Sphinx, doc: document) -> str:


@pytest.fixture
def make_module():
def make_module(tmp_path):
added_modules = []

def make_module(name, code):
code = dedent(code)
assert name not in sys.modules
spec = importlib.util.spec_from_loader(name, loader=None)
mod = sys.modules[name] = importlib.util.module_from_spec(spec)
exec(dedent(code), mod.__dict__)
path = tmp_path / f"{name}_{str(uuid4()).replace('-', '_')}.py"
path.write_text(code)
mod.__file__ = str(path)
exec(code, mod.__dict__)
linecache.updatecache(str(path), mod.__dict__)
added_modules.append(name)
return mod

Expand Down
60 changes: 21 additions & 39 deletions tests/test_elegant_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ def app(make_app_setup) -> Sphinx:

@pytest.fixture
def process_doc(app):
app.config.typehints_fully_qualified = True

def process(fn: t.Callable) -> t.List[str]:
lines = inspect.getdoc(fn).split("\n")
sat.process_docstring(app, "function", fn.__name__, fn, None, lines)
Expand All @@ -73,7 +71,7 @@ def test_app(app):


def test_default(app):
assert format_annotation(str) == ":py:class:`str`"
assert format_annotation(str, app.config) is None


def test_alternatives(process_doc):
Expand Down Expand Up @@ -125,26 +123,24 @@ def fn_test(m: t.Mapping[str, int] = {}):

assert process_doc(fn_test) == [
":type m: "
r":annotation-terse:`:py:class:\`typing.Mapping\``\ "
r":annotation-terse:`:py:class:\`~typing.Mapping\``\ "
r":annotation-full:`"
r":py:class:\`typing.Mapping\`\[:py:class:\`str\`, :py:class:\`int\`]"
r":py:class:\`~typing.Mapping\`\[:py:class:\`str\`, :py:class:\`int\`]"
"` (default: ``{}``)",
":param m: Test M",
]


def test_mapping(app):
assert _format_terse(t.Mapping[str, t.Any]) == ":py:class:`~typing.Mapping`"
assert _format_full(t.Mapping[str, t.Any]) == (
r":py:class:`~typing.Mapping`\["
r":py:class:`str`, "
r":py:data:`~typing.Any`"
r"]"
assert (
_format_terse(t.Mapping[str, t.Any], app.config)
== ":py:class:`~typing.Mapping`"
)
assert _format_full(t.Mapping[str, t.Any], app.config) is None


def test_dict(app):
assert _format_terse(t.Dict[str, t.Any]) == (
assert _format_terse(t.Dict[str, t.Any], app.config) == (
"{:py:class:`str`: :py:data:`~typing.Any`}"
)

Expand All @@ -160,45 +156,37 @@ def test_dict(app):
],
)
def test_callable_terse(app, annotation, expected):
assert _format_terse(annotation) == expected
assert _format_terse(annotation, app.config) == expected


def test_literal(app):
assert _format_terse(Literal["str", 1, None]) == "{'str', 1, None}"
assert _format_full(Literal["str", 1, None]) == (
r":py:data:`~typing.Literal`\['str', 1, None]"
)
assert _format_terse(Literal["str", 1, None], app.config) == "{'str', 1, None}"
assert _format_full(Literal["str", 1, None], app.config) is None


def test_qualname_overrides_class(app, _testmod):
assert _testmod.Class.__module__ == "_testmod"
assert _format_terse(_testmod.Class) == ":py:class:`~test.Class`"
assert _format_terse(_testmod.Class, app.config) == ":py:class:`~test.Class`"


def test_qualname_overrides_exception(app, _testmod):
assert _testmod.Excep.__module__ == "_testmod"
assert _format_terse(_testmod.Excep) == ":py:exc:`~test.Excep`"
assert _format_terse(_testmod.Excep, app.config) == ":py:exc:`~test.Excep`"


def test_qualname_overrides_recursive(app, _testmod):
assert _format_terse(t.Union[_testmod.Class, str]) == (
assert _format_terse(t.Union[_testmod.Class, str], app.config) == (
r":py:class:`~test.Class` | :py:class:`str`"
)
assert _format_full(t.Union[_testmod.Class, str]) == (
r":py:data:`~typing.Union`\["
r":py:class:`~test.Class`, "
r":py:class:`str`"
r"]"
)
assert _format_full(t.Union[_testmod.Class, str], app.config) is None


def test_fully_qualified(app, _testmod):
assert _format_terse(t.Union[_testmod.Class, str], True) == (
app.config.typehints_fully_qualified = True
assert _format_terse(t.Union[_testmod.Class, str], app.config) == (
r":py:class:`test.Class` | :py:class:`str`"
)
assert _format_full(t.Union[_testmod.Class, str], True) == (
r":py:data:`typing.Union`\[" r":py:class:`test.Class`, " r":py:class:`str`" r"]"
)
assert _format_full(t.Union[_testmod.Class, str], app.config) is None


def test_classes_get_added(app, parse):
Expand Down Expand Up @@ -228,6 +216,7 @@ def test_classes_get_added(app, parse):
ids=lambda p: str(p).replace("typing.", ""),
)
def test_typing_classes(app, annotation, formatter):
app.config.typehints_fully_qualified = True
name = (
getattr(annotation, "_name", None)
or getattr(annotation, "__name__", None)
Expand All @@ -240,15 +229,8 @@ def test_typing_classes(app, annotation, formatter):
args = get_args(annotation)
if name == "Union" and len(args) == 2 and type(None) in args:
name = "Optional"
assert formatter(annotation, True).startswith(f":py:data:`typing.{name}")


def test_typing_class_nested(app):
assert _format_full(t.Optional[t.Tuple[int, str]]) == (
":py:data:`~typing.Optional`\\["
":py:data:`~typing.Tuple`\\[:py:class:`int`, :py:class:`str`]"
"]"
)
output = formatter(annotation, app.config)
assert output is None or output.startswith(f":py:data:`typing.{name}")


@pytest.mark.parametrize(
Expand Down

0 comments on commit 0051bf4

Please sign in to comment.