11from __future__ import annotations
22
3+ import re
34import sys
45import textwrap
56import warnings
67from importlib .metadata import version
8+ from pathlib import Path
79from textwrap import indent
810from types import MethodType
9- from typing import TYPE_CHECKING , cast
11+ from typing import TYPE_CHECKING , Any , Generic , Literal , cast , get_origin
1012
1113if sys .version_info >= (3 , 13 ):
1214 from warnings import deprecated as deprecated
1315else :
1416 from typing_extensions import deprecated as deprecated
1517
16- from pydocstring import Docstring , Parameter , Return , Section , SectionKind , Style , emit_google , emit_numpy , parse
18+ from jinja2 .defaults import DEFAULT_FILTERS # type: ignore[attr-defined]
19+ from jinja2 .utils import import_string
20+ from pydocstring import (
21+ Docstring ,
22+ GoogleDocstring ,
23+ NumPyDocstring ,
24+ NumPySectionKind ,
25+ Parameter ,
26+ PlainDocstring ,
27+ Return ,
28+ Section ,
29+ SectionKind ,
30+ Style ,
31+ emit_google ,
32+ emit_numpy ,
33+ parse ,
34+ )
35+ from sphinx .ext .napoleon import NumpyDocstring # type: ignore[attr-defined]
1736
1837from .._deprecated import Deprecation , deprecated_arg
1938from .._extensions import _NSInfo
3049
3150
3251if TYPE_CHECKING :
52+ from collections .abc import Generator , Iterable
53+
3354 from sphinx .application import Sphinx
3455 from sphinx .ext .autodoc import Options as AutodocOptions
3556 from sphinx .ext .autodoc import _AutodocObjType # type: ignore[attr-defined]
@@ -43,10 +64,58 @@ def setup(app: Sphinx) -> ExtensionMetadata: # noqa: D103
4364 app .setup_extension ("sphinx.ext.autodoc" )
4465 # To go first, we use a lower number than napoleon (which uses the default, 500)
4566 app .connect ("autodoc-process-docstring" , _process_docstring , priority = 100 )
67+ app .connect ("autodoc-process-bases" , _skip_private_bases )
68+
69+ DEFAULT_FILTERS ["member_type" ] = _member_type
70+
71+ app .config .templates_path = list (app .config .templates_path ) + [str (Path (__file__ ).parent / "templates" )]
72+
73+ NumpyDocstring ._parse_returns_section = _parse_returns_section # type: ignore[assignment]
4674
4775 return {"version" : version ("scverse-misc" ), "parallel_read_safe" : True }
4876
4977
78+ def _process_return (lines : Iterable [str ]) -> Generator [str , None , None ]:
79+ for line in lines :
80+ if m := re .fullmatch (r"(?P<param>\w+)\s+:\s+(?P<type>[\w.]+)" , line ):
81+ yield f"-{ m ['param' ]} (:class:`~{ m ['type' ]} `)"
82+ else :
83+ yield line
84+
85+
86+ def _parse_returns_section (self : NumpyDocstring , section : str ) -> list [str ]:
87+ """Add support for prose return sections in Numpy-style docstrings."""
88+ lines_raw = self ._dedent (self ._consume_to_next_section ())
89+ if lines_raw [0 ] == ":" :
90+ del lines_raw [0 ]
91+ lines = self ._format_block (":returns: " , list (_process_return (lines_raw )))
92+ if lines and lines [- 1 ]:
93+ lines .append ("" )
94+ return lines
95+
96+
97+ def _skip_private_bases (app : Sphinx , name : str , obj : type , _unused : Any , bases : list [type ]) -> None : # noqa: ANN401
98+ bases [:] = [b for b in bases if b is not object and get_origin (b ) is not Generic and not b .__name__ .startswith ("_" )]
99+
100+
101+ def _member_type (obj_path : str ) -> Literal ["method" , "property" , "attribute" ]:
102+ """Determine object member type.
103+
104+ E.g.: `.. auto{{ fullname | member_type }}::`
105+ """
106+ # https://jinja.palletsprojects.com/en/stable/api/#custom-filters
107+ cls_path , member_name = obj_path .rsplit ("." , 1 )
108+ cls = import_string (cls_path )
109+ member = getattr (cls , member_name , None )
110+ match member :
111+ case property ():
112+ return "property"
113+ case _ if callable (member ):
114+ return "method"
115+ case _:
116+ return "attribute"
117+
118+
50119def _process_docstring (
51120 app : Sphinx , objtype : _AutodocObjType , name : str , obj : object , options : AutodocOptions , lines : list [str ]
52121) -> None :
@@ -62,13 +131,35 @@ def _process_docstring(
62131 if hasattr (obj , ATTR_DEPRECATED ) and isinstance (msg := getattr (obj , ATTR_DEPRECATED , None ), Deprecation ):
63132 _process_deprecated_function (app , msg , lines )
64133 if (args := getattr (obj , ATTR_DEPRECATED_ARG , None )) is not None :
65- _process_deprecated_args (args , lines )
134+ _process_deprecated_args (app , args , lines )
66135 case "data" if isinstance (obj , Settings ):
67136 _process_settings_object (obj , name , lines )
68137
69138
70- def _emit_docstring (app : Sphinx , model : Docstring , lines : list [str ]) -> None :
139+ def _emit_docstring (
140+ app : Sphinx ,
141+ model : Docstring ,
142+ lines : list [str ],
143+ parsed : NumPyDocstring | GoogleDocstring | PlainDocstring | None = None ,
144+ ) -> None :
71145 """Emit a docstring compatible with the user settings (i.e. renderable with the chosen napoleon settings)."""
146+ # pydocstring doesn't support prose return sections and tries to parse them as NumPy style.
147+ # Copy the original return section verbatim.
148+ if parsed is not None and isinstance (parsed , NumPyDocstring ):
149+ sections = model .sections
150+ return_section = None
151+ for i , section in enumerate (sections ):
152+ if section .kind == SectionKind .RETURNS :
153+ return_section = i
154+ break
155+
156+ if return_section is not None :
157+ ast_section = parsed .sections [return_section ]
158+ assert ast_section .section_kind == NumPySectionKind .RETURNS
159+ text = "\n " .join (parsed .source [ast_section .range .start : ast_section .range .end ].splitlines ()[2 :])
160+ sections [i ] = Section (SectionKind .UNKNOWN , unknown_name = ast_section .header_name .text , body = text )
161+ model .sections = sections
162+
72163 if getattr (app .config , "napoleon_google_docstring" , True ):
73164 doc = emit_google (model )
74165 elif getattr (app .config , "napoleon_numpy_docstring" , True ):
@@ -91,10 +182,10 @@ def _process_deprecated_function(app: Sphinx, msg: Deprecation, lines: list[str]
91182 if model .extended_summary is not None :
92183 notice += f"\n \n { model .extended_summary } "
93184 model .extended_summary = notice
94- _emit_docstring (app , model , lines )
185+ _emit_docstring (app , model , lines , parsed )
95186
96187
97- def _process_deprecated_args (deprecations : list [deprecated_arg ], lines : list [str ]) -> None :
188+ def _process_deprecated_args (app : Sphinx , deprecations : list [deprecated_arg ], lines : list [str ]) -> None :
98189 parsed = parse ("\n " .join (lines ))
99190 if parsed .style is Style .PLAIN :
100191 return
@@ -124,15 +215,8 @@ def _process_deprecated_args(deprecations: list[deprecated_arg], lines: list[str
124215 sections = model .sections
125216 sections [s ] = Section (section .kind , parameters = params )
126217 model .sections = sections
127- match parsed .style :
128- case Style .GOOGLE :
129- doc = emit_google (model )
130- case Style .NUMPY :
131- doc = emit_numpy (model )
132- case _: # pragma: no cover
133- raise AssertionError
134218
135- lines [:] = doc . strip ( " \n " ). splitlines ( )
219+ _emit_docstring ( app , model , lines , parsed )
136220
137221
138222_settings_docstring_template = """Allows users to customize settings for the `{package}` package.
0 commit comments