diff --git a/pyproject.toml b/pyproject.toml index 875196b..e006a35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'] diff --git a/scanpydoc/elegant_typehints/__init__.py b/scanpydoc/elegant_typehints/__init__.py index c38784e..3a10450 100644 --- a/scanpydoc/elegant_typehints/__init__.py +++ b/scanpydoc/elegant_typehints/__init__.py @@ -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 @@ -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("-")) diff --git a/scanpydoc/elegant_typehints/formatting.py b/scanpydoc/elegant_typehints/formatting.py index 528a876..71b3d4c 100644 --- a/scanpydoc/elegant_typehints/formatting.py +++ b/scanpydoc/elegant_typehints/formatting.py @@ -1,7 +1,9 @@ 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 @@ -9,7 +11,6 @@ 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 @@ -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): @@ -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: @@ -85,14 +74,10 @@ 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`, @@ -100,36 +85,23 @@ def format_annotation( 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)}`" diff --git a/scanpydoc/elegant_typehints/return_tuple.py b/scanpydoc/elegant_typehints/return_tuple.py index 8fa838e..3297a78 100644 --- a/scanpydoc/elegant_typehints/return_tuple.py +++ b/scanpydoc/elegant_typehints/return_tuple.py @@ -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}"] diff --git a/tests/conftest.py b/tests/conftest.py index bba3fb9..2e28075 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -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 diff --git a/tests/test_elegant_typehints.py b/tests/test_elegant_typehints.py index 37bad22..b4d52b1 100644 --- a/tests/test_elegant_typehints.py +++ b/tests/test_elegant_typehints.py @@ -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) @@ -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): @@ -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`}" ) @@ -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): @@ -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) @@ -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(