Skip to content

Commit c9d3f1e

Browse files
authored
Add Sphinx docs customization for multimethod (#1088)
* Add Sphinx docs customization for multimethod * Customize sphinx autosummary for multimethod Handle multimethod class methods in automodule customization Update sphinx pin Update .gitignore for vim * Set override=True when adding autosummary directive * Change .gitignore, only ignore hidden .swp
1 parent a5fadeb commit c9d3f1e

File tree

6 files changed

+311
-4
lines changed

6 files changed

+311
-4
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ MANIFEST
1010
out.*
1111
res?.dxf
1212
.~*
13+
.*.swp
1314
assy.wrl
1415
assy.xml
1516
assy.zip

cadquery/sketch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,7 @@ def arc(
782782
forConstruction: bool = False,
783783
) -> T:
784784
"""
785-
Construct an arc starting at p1, through p2, ending at p3
785+
Construct an arc.
786786
"""
787787

788788
val = Edge.makeThreePointArc(Vector(p1), Vector(p2), Vector(p3))

doc/conf.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
# If extensions (or modules to document with autodoc) are in another directory,
2525
# add these directories to sys.path here. If the directory is relative to the
2626
# documentation root, use os.path.abspath to make it absolute, like shown here.
27-
# sys.path.insert(0, os.path.abspath('.'))
27+
sys.path.insert(0, os.path.abspath("./ext"))
2828

2929

3030
# -- General configuration -----------------------------------------------------
@@ -40,6 +40,7 @@
4040
"sphinx.ext.autosummary",
4141
"cadquery.cq_directive",
4242
"sphinx.ext.mathjax",
43+
"sphinx_autodoc_multimethod",
4344
]
4445

4546
autodoc_typehints = "both"

doc/ext/sphinx_autodoc_multimethod.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
from types import ModuleType
2+
from typing import Any, List, Tuple, ValuesView
3+
from multimethod import multimethod
4+
import re
5+
6+
from sphinx.ext.autosummary import Autosummary
7+
from sphinx.ext.autosummary import (
8+
get_import_prefixes_from_env,
9+
ImportExceptionGroup,
10+
mangle_signature,
11+
extract_summary,
12+
)
13+
from docutils.statemachine import StringList
14+
from sphinx.pycode import ModuleAnalyzer, PycodeError
15+
16+
from sphinx.ext.autodoc import MethodDocumenter as SphinxMethodDocumenter
17+
18+
from sphinx.util import inspect, logging
19+
from sphinx.util.inspect import evaluate_signature, safe_getattr, stringify_signature
20+
from sphinx.util.typing import get_type_hints
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
def get_first(obj):
26+
"""Use to return first element (first param type annotation or first registered multimethod)."""
27+
return next(iter(obj))
28+
29+
30+
patself = re.compile(r"\W*:\W*param\W+self.*")
31+
patparam = re.compile(r"\W*:\W*param.*")
32+
33+
34+
def process_docstring_multimethod(app, what, name, obj, options, lines):
35+
"""multimethod docstring customization
36+
37+
* formatting and remove extraneous signatures
38+
* insert self in front of param field list for instance methods if type hinted
39+
"""
40+
41+
if what == "method" and isinstance(obj, multimethod):
42+
# instance or static method
43+
44+
insert_first_param = False
45+
if (
46+
app.config.autodoc_typehints in ("both", "description")
47+
and app.config.autodoc_typehints_description_target in ("all")
48+
and get_first(get_type_hints(get_first(obj.values()))) == "self"
49+
):
50+
insert_first_param = True
51+
52+
lines_replace = []
53+
54+
# handle functools.singledispatch style register (multiple names)
55+
methods = set(m.__name__ for m in obj.values())
56+
patsig = re.compile(rf"\W*[{'|'.join(methods)}]+\(.*\).*")
57+
58+
iparam = None
59+
for i, line in enumerate(lines):
60+
if patsig.match(line):
61+
lines_replace.append("")
62+
continue
63+
if insert_first_param and patself.match(line):
64+
# exists explicitly in field list
65+
insert_first_param = False
66+
elif insert_first_param and not iparam and patparam.match(line):
67+
iparam = i
68+
lines_replace.append(line.lstrip())
69+
70+
if insert_first_param and iparam:
71+
lines_replace.insert(iparam, ":param self:")
72+
73+
del lines[:]
74+
lines.extend(lines_replace)
75+
76+
elif what == "method" and inspect.isclassmethod(obj) and hasattr(obj, "pending"):
77+
78+
if obj.pending:
79+
methods = set(m.__name__ for m in obj.pending)
80+
else:
81+
methods = set(m.__name__ for m in obj.__func__.values())
82+
83+
lines_replace = []
84+
patsig = re.compile(rf"\W*[{'|'.join(methods)}]+\(.*\).*")
85+
86+
for line in lines:
87+
if patsig.match(line):
88+
lines_replace.append("")
89+
else:
90+
lines_replace.append(line.lstrip())
91+
92+
del lines[:]
93+
lines.extend(lines_replace)
94+
95+
96+
class MultimethodAutosummary(Autosummary):
97+
"""Customize autosummary multimethod signature."""
98+
99+
def get_items(self, names: List[str]) -> List[Tuple[str, str, str, str]]:
100+
"""Try to import the given names, and return a list of
101+
``[(name, signature, summary_string, real_name), ...]``.
102+
"""
103+
prefixes = get_import_prefixes_from_env(self.env)
104+
105+
items: List[Tuple[str, str, str, str]] = []
106+
107+
max_item_chars = 50
108+
109+
for name in names:
110+
display_name = name
111+
if name.startswith("~"):
112+
name = name[1:]
113+
display_name = name.split(".")[-1]
114+
115+
try:
116+
real_name, obj, parent, modname = self.import_by_name(
117+
name, prefixes=prefixes
118+
)
119+
except ImportExceptionGroup as exc:
120+
errors = list(
121+
set("* %s: %s" % (type(e).__name__, e) for e in exc.exceptions)
122+
)
123+
logger.warning(
124+
__("autosummary: failed to import %s.\nPossible hints:\n%s"),
125+
name,
126+
"\n".join(errors),
127+
location=self.get_location(),
128+
)
129+
continue
130+
131+
self.bridge.result = StringList() # initialize for each documenter
132+
full_name = real_name
133+
if not isinstance(obj, ModuleType):
134+
# give explicitly separated module name, so that members
135+
# of inner classes can be documented
136+
full_name = modname + "::" + full_name[len(modname) + 1 :]
137+
# NB. using full_name here is important, since Documenters
138+
# handle module prefixes slightly differently
139+
documenter = self.create_documenter(self.env.app, obj, parent, full_name)
140+
if not documenter.parse_name():
141+
logger.warning(
142+
__("failed to parse name %s"),
143+
real_name,
144+
location=self.get_location(),
145+
)
146+
items.append((display_name, "", "", real_name))
147+
continue
148+
if not documenter.import_object():
149+
logger.warning(
150+
__("failed to import object %s"),
151+
real_name,
152+
location=self.get_location(),
153+
)
154+
items.append((display_name, "", "", real_name))
155+
continue
156+
157+
# try to also get a source code analyzer for attribute docs
158+
try:
159+
documenter.analyzer = ModuleAnalyzer.for_module(
160+
documenter.get_real_modname()
161+
)
162+
# parse right now, to get PycodeErrors on parsing (results will
163+
# be cached anyway)
164+
documenter.analyzer.find_attr_docs()
165+
except PycodeError as err:
166+
logger.debug("[autodoc] module analyzer failed: %s", err)
167+
# no source file -- e.g. for builtin and C modules
168+
documenter.analyzer = None
169+
170+
# -- Grab the signature
171+
172+
try:
173+
sig = documenter.format_signature(show_annotation=False)
174+
# -- multimethod customization
175+
if isinstance(obj, multimethod):
176+
sig = "(...)"
177+
# -- end customization
178+
except TypeError:
179+
# the documenter does not support ``show_annotation`` option
180+
sig = documenter.format_signature()
181+
182+
if not sig:
183+
sig = ""
184+
else:
185+
max_chars = max(10, max_item_chars - len(display_name))
186+
sig = mangle_signature(sig, max_chars=max_chars)
187+
188+
# -- Grab the summary
189+
190+
documenter.add_content(None)
191+
summary = extract_summary(self.bridge.result.data[:], self.state.document)
192+
193+
items.append((display_name, sig, summary, real_name))
194+
195+
return items
196+
197+
198+
class MethodDocumenter(SphinxMethodDocumenter):
199+
"""Customize to append multimethod signatures."""
200+
201+
def append_signature_multiple_dispatch(self, methods: ValuesView[Any]):
202+
203+
sigs = []
204+
for dispatchmeth in methods:
205+
documenter = MethodDocumenter(self.directive, "")
206+
documenter.parent = self.parent
207+
documenter.object = dispatchmeth
208+
documenter.objpath = [None]
209+
sigs.append(documenter.format_signature())
210+
211+
return sigs
212+
213+
def format_signature(self, **kwargs: Any) -> str:
214+
if self.config.autodoc_typehints_format == "short":
215+
kwargs.setdefault("unqualified_typehints", True)
216+
217+
sigs = []
218+
if (
219+
self.analyzer
220+
and ".".join(self.objpath) in self.analyzer.overloads
221+
and self.config.autodoc_typehints != "none"
222+
):
223+
# Use signatures for overloaded methods instead of the implementation method.
224+
overloaded = True
225+
else:
226+
overloaded = False
227+
sig = super(SphinxMethodDocumenter, self).format_signature(**kwargs)
228+
sigs.append(sig)
229+
230+
meth = self.parent.__dict__.get(self.objpath[-1])
231+
if inspect.is_singledispatch_method(meth):
232+
# append signature of singledispatch'ed functions
233+
for typ, func in meth.dispatcher.registry.items():
234+
if typ is object:
235+
pass # default implementation. skipped.
236+
else:
237+
dispatchmeth = self.annotate_to_first_argument(func, typ)
238+
if dispatchmeth:
239+
documenter = MethodDocumenter(self.directive, "")
240+
documenter.parent = self.parent
241+
documenter.object = dispatchmeth
242+
documenter.objpath = [None]
243+
sigs.append(documenter.format_signature())
244+
# -- multimethod customization
245+
elif isinstance(meth, multimethod):
246+
sigs = self.append_signature_multiple_dispatch(meth.values())
247+
elif inspect.isclassmethod(self.object) and hasattr(self.object, "pending"):
248+
if self.object.pending:
249+
methods = self.object.pending
250+
else:
251+
methods = self.object.__func__.values()
252+
sigs = self.append_signature_multiple_dispatch(methods)
253+
elif inspect.isstaticmethod(meth) and isinstance(self.object, multimethod):
254+
sigs = []
255+
methods = self.object.values()
256+
for dispatchmeth in methods:
257+
actual = inspect.signature(
258+
dispatchmeth,
259+
bound_method=False,
260+
type_aliases=self.config.autodoc_type_aliases,
261+
)
262+
sig = stringify_signature(actual, **kwargs)
263+
sigs.append(sig)
264+
# -- end customization
265+
if overloaded:
266+
if inspect.isstaticmethod(
267+
self.object, cls=self.parent, name=self.object_name
268+
):
269+
actual = inspect.signature(
270+
self.object,
271+
bound_method=False,
272+
type_aliases=self.config.autodoc_type_aliases,
273+
)
274+
else:
275+
actual = inspect.signature(
276+
self.object,
277+
bound_method=True,
278+
type_aliases=self.config.autodoc_type_aliases,
279+
)
280+
281+
__globals__ = safe_getattr(self.object, "__globals__", {})
282+
for overload in self.analyzer.overloads.get(".".join(self.objpath)):
283+
overload = self.merge_default_value(actual, overload)
284+
overload = evaluate_signature(
285+
overload, __globals__, self.config.autodoc_type_aliases
286+
)
287+
288+
if not inspect.isstaticmethod(
289+
self.object, cls=self.parent, name=self.object_name
290+
):
291+
parameters = list(overload.parameters.values())
292+
overload = overload.replace(parameters=parameters[1:])
293+
sig = stringify_signature(overload, **kwargs)
294+
sigs.append(sig)
295+
296+
return "\n".join(sigs)
297+
298+
299+
def setup(app):
300+
301+
app.connect("autodoc-process-docstring", process_docstring_multimethod)
302+
app.add_directive("autosummary", MultimethodAutosummary, override=True)
303+
app.add_autodocumenter(MethodDocumenter, override=True)
304+
305+
return {"parallel_read_safe": True, "parallel_write_safe": True}

doc/quickstart.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
CadQuery QuickStart
55
***********************
66

7-
.. module:: cadquery
7+
.. currentmodule:: cadquery
88

99
Want a quick glimpse of what CadQuery can do? This quickstart will demonstrate the basics of CadQuery using a simple example
1010

environment.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ dependencies:
99
- ocp=7.5.3
1010
- vtk=9.0.1
1111
- pyparsing>=2.1.9
12-
- sphinx=4.4.0
12+
- sphinx=5.0.1
1313
- sphinx_rtd_theme
1414
- black=19.10b0
1515
- click=8.0.4

0 commit comments

Comments
 (0)