Skip to content

Commit 361e7a8

Browse files
authored
Add autogenerated docs for Python API (#129)
Automatically generate docs for docstub (the Python package) with autosummary, including source links to the GitHub repository. For now the entire API is private but it might still be a useful reference for a potential plugin system. This isn't perfect yet. For example, it seems that click commands are somehow not picked up by autosummary. But that's something for later.
1 parent a29cf43 commit 361e7a8

23 files changed

Lines changed: 352 additions & 31 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Project specific folders
22
stubs/
3+
docs/python_api/
34

45
# Byte-compiled / optimized / DLL files
56
__pycache__/

‎docs/conf.py‎

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
# Configuration file for the Sphinx documentation builder.
22
# https://www.sphinx-doc.org/en/master/usage/configuration.html
33

4+
# ruff: noqa: T201
5+
6+
import inspect
7+
import shutil
8+
import sys
49
from datetime import date
10+
from functools import cache
11+
from pathlib import Path
12+
from subprocess import check_output
513

614
import setuptools_scm
715

16+
import docstub
17+
from docstub._path_utils import walk_source_package
18+
from docstub._utils import module_name_from_path
19+
820
# -- Project information ------------------------------------------------------
921

1022
project = "docstub"
@@ -22,14 +34,19 @@
2234

2335
extensions = [
2436
"sphinx.ext.intersphinx",
37+
"sphinx.ext.autodoc",
38+
"sphinx.ext.autosummary",
39+
"sphinx.ext.linkcode",
2540
"sphinx_copybutton",
26-
# https://numpydoc.readthedocs.io/
2741
"myst_parser",
42+
"numpydoc",
2843
]
2944

3045
intersphinx_mapping = {
3146
"python": ("https://docs.python.org/3/", None),
3247
"typing": ("https://typing.python.org/en/latest/", None),
48+
"lark": ("https://lark-parser.readthedocs.io/en/latest/", None),
49+
"libcst": ("https://libcst.readthedocs.io/en/latest/", None),
3350
}
3451

3552
myst_enable_extensions = [
@@ -41,6 +58,79 @@
4158

4259
myst_heading_anchors = 3
4360

61+
autosummary_generate = True
62+
63+
autodoc_default_options = {"show-inheritance": True, "private-members": True}
64+
65+
autodoc_member_order = "alphabetical"
66+
67+
numpydoc_show_class_members = False
68+
69+
add_module_names = False
70+
71+
72+
@cache
73+
def get_git_revision_hash():
74+
"""Extract hash of current revision."""
75+
output = check_output(["git", "rev-parse", "HEAD"])
76+
full_hash = output.decode().strip()
77+
return full_hash
78+
79+
80+
def linkcode_resolve(domain, info):
81+
"""Resolve link for code objects.
82+
83+
This function is a hook called by the sphinx extension linkcode [1]_.
84+
Loosely inspired by a function in NumPy [2]_.
85+
86+
Parameters
87+
----------
88+
domain : str
89+
Specifies the language domain of the given object.
90+
info : dict
91+
A dictionary describing the object.
92+
93+
Returns
94+
-------
95+
url : str
96+
An URL to the source code.
97+
98+
References
99+
---------
100+
.. [1] http://www.sphinx-doc.org/en/master/usage/extensions/linkcode.html
101+
.. [2] https://github.com/numpy/numpy/blob/cbf3a081271a43e980e3c2f76625deb43fd53922/doc/source/conf.py#L303-L357
102+
"""
103+
if domain != "py" or "module" not in info or "fullname" not in info:
104+
return None
105+
106+
# Get reference to the object, start with the module
107+
obj = sys.modules.get(info["module"])
108+
for part in info["fullname"].split("."):
109+
try:
110+
obj = getattr(obj, part)
111+
except AttributeError:
112+
# Not all attributes exist, e.g. instance attributes
113+
return None
114+
# Strip decorators which would resolve to the source of the decorator
115+
obj = inspect.unwrap(obj)
116+
117+
file = info["module"].replace(".", "/")
118+
try:
119+
source, start_line = inspect.getsourcelines(obj)
120+
except (TypeError, OSError):
121+
# Skip unexpected types, occurs when `obj` isn't a module, class, method,
122+
# function, etc.
123+
return None
124+
stop_line = start_line + len(source) - 1
125+
126+
template = "https://github.com/scientific-python/docstub/blob/{revision}/src/{file}.py#L{start}-L{stop}"
127+
revision = get_git_revision_hash()
128+
assert len(revision) == 40
129+
url = template.format(
130+
revision=revision, file=file, start=start_line, stop=stop_line
131+
)
132+
return url
133+
44134

45135
# -- HTML output --------------------------------------------------------------
46136

@@ -79,3 +169,112 @@
79169
"sidebar/variant-selector.html",
80170
]
81171
}
172+
173+
174+
# -- Pregenerate doc sources ---------------------------------------------------
175+
176+
PYTHON_API_TEMPLATE = """.. Generated by `conf.py`
177+
178+
Python API
179+
==========
180+
181+
Docstub is primarily intended to be used as a command line tool.
182+
Nevertheless we document it's Python facing API here.
183+
184+
Modules
185+
-------
186+
187+
.. autosummary::
188+
:toctree:
189+
:recursive:
190+
:template: module.rst
191+
:nosignatures:
192+
193+
"""
194+
195+
196+
def is_private_qualname(name):
197+
parts = name.split(".")
198+
return any(p.startswith("_") for p in parts)
199+
200+
201+
def write_python_api(module, *, exclude_modules=()):
202+
"""Create ``python_api/index.rst`` as an entrypoint for autosummary.
203+
204+
Parameters
205+
----------
206+
module : ModuleType
207+
exclude_modules : tuple[str, ...]
208+
"""
209+
package_path = Path(module.__file__).parent
210+
211+
names = sorted(
212+
module_name_from_path(path) for path in walk_source_package(package_path)
213+
)
214+
names = [n for n in names if n not in exclude_modules]
215+
216+
# Sort private modules after public ones
217+
public_names = [n for n in names if not is_private_qualname(n)]
218+
private_names = [n for n in names if is_private_qualname(n)]
219+
names = public_names + private_names
220+
221+
names = [f" {name}" for name in names]
222+
content = "\n".join([PYTHON_API_TEMPLATE, *names])
223+
224+
api_dir = Path(__file__).parent / "python_api"
225+
if api_dir.exists():
226+
shutil.rmtree(api_dir)
227+
print(f"[conf.py] deleted {api_dir}")
228+
229+
api_dir.mkdir()
230+
index_path = api_dir / "index.rst"
231+
index_path.write_text(content)
232+
print(f"[conf.py] wrote {index_path}")
233+
234+
235+
write_python_api(
236+
docstub,
237+
exclude_modules=("docstub.__main__", "docstub._version"),
238+
)
239+
240+
241+
# -- Patch Sphinx --------------------------------------------------------------
242+
243+
244+
def autodoc_skip_members(app, obj_type, name, obj, skip, options):
245+
"""Force autosummary to show private members too."""
246+
# TODO ensure that click commands are included as well
247+
if name.startswith("_") and not name.startswith("__"):
248+
# Force autosummary to show private members to,
249+
# `autodoc_default_options = {"private-members": True}`
250+
# doesn't solve this on its own
251+
return False
252+
return None # Default behavior
253+
254+
255+
def truncate_signature_defaults(
256+
app, what, name, obj, options, signature, return_annotation
257+
):
258+
"""Truncate long defaults in function signatures."""
259+
if what in {"module", "property", "attribute"}:
260+
return signature, return_annotation
261+
262+
try:
263+
signature_object = inspect.signature(obj)
264+
except (TypeError, ValueError):
265+
pass
266+
else:
267+
for param in signature_object.parameters.values():
268+
if param.default is inspect.Parameter.empty:
269+
continue
270+
default = repr(param.default)
271+
if len(default) > 30:
272+
truncated = default[:12] + "..." + default[-12:]
273+
signature = signature.replace(default, truncated)
274+
275+
return signature, return_annotation
276+
277+
278+
def setup(app):
279+
app.connect("autodoc-process-signature", truncate_signature_defaults)
280+
app.connect("autodoc-skip-member", autodoc_skip_members)

‎docs/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ development/index
3232
command_line
3333
configuration
3434
typing_syntax
35+
python_api/index
3536
glossary
3637
release_notes/index
3738
:::

‎docs/static/furo_overrides.css‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,17 @@ article .align-default {
3333
.sidebar-brand-text {
3434
margin-bottom: 0;
3535

36-
}
36+
}
37+
38+
/* Use more readable sizes and spacing for headings */
39+
h2, .h2 {
40+
font-size: 1.5em;
41+
margin-top: 3rem;
42+
}
43+
h3, .h3 {
44+
font-size: 1.25em;
45+
margin-top: 1rem;
46+
}
47+
h4, .h4 {
48+
font-size: 1.1em;
49+
}

‎docs/templates/module.rst‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
``{{ fullname }}``
2+
=={{ underline }}==
3+
4+
.. currentmodule:: {{ fullname }}
5+
6+
.. automodule:: {{ fullname }}
7+
8+
{% block functions %}
9+
{% if functions %}
10+
{{ _('Functions') | underline(line='-') }}
11+
12+
.. autosummary::
13+
:toctree:
14+
:template: objects.rst
15+
:nosignatures:
16+
17+
{% for item in functions if not item.startswith("_") %}
18+
{{ item -}}
19+
{% endfor %}
20+
{% for item in functions if item.startswith("_") %}
21+
{{ item -}}
22+
{% endfor %}
23+
{% endif %}
24+
{%- endblock %}
25+
26+
{% block classes %}
27+
{% if classes %}
28+
{{ _('Classes') | underline(line='-') }}
29+
30+
.. autosummary::
31+
:toctree:
32+
:recursive:
33+
:template: objects.rst
34+
:nosignatures:
35+
36+
{% for item in classes if not item.startswith("_") %}
37+
{{ item -}}
38+
{% endfor %}
39+
{% for item in classes if item.startswith("_") %}
40+
{{ item -}}
41+
{% endfor %}
42+
{% endif %}
43+
{%- endblock %}

‎docs/templates/objects.rst‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
``{{ name }}``
2+
=={{ underline }}==
3+
4+
in module :mod:`{{ module }}`.
5+
6+
.. currentmodule:: {{ module }}
7+
8+
{% if objtype == "class" -%}
9+
.. autoclass:: {{ fullname }}
10+
:members:
11+
:private-members:
12+
:special-members:
13+
{% else %}
14+
.. auto{{ objtype }}:: {{ fullname }}
15+
{% endif %}

‎src/docstub-stubs/_config.pyi‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ logger: logging.Logger
1212

1313
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
1414
class Config:
15+
1516
TEMPLATE_PATH: ClassVar[Path]
1617
NUMPY_PATH: ClassVar[Path]
1718

‎src/docstub/__init__.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# Copyright (c) 2024 Lars Grüter. All rights reserved.
22

3-
"""Generate Python stub files (PYI) from docstrings."""
3+
"""Generate Python stub files (PYI) from docstrings.
4+
5+
Attributes
6+
----------
7+
__version__ : str
8+
The current version.
9+
"""
410

511
from ._version import __version__
612

‎src/docstub/_analysis.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Collect type information."""
1+
"""Collection of type information."""
22

33
import builtins
44
import importlib

‎src/docstub/_app_generate_stubs.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
"""Implementation of `docstub run`.
1+
"""Implementation of ``docstub run``.
22
3-
Its interface declaration is in `_cli.py`.
3+
Its interface declaration is in ``_cli.py``. Splitting declaration and
4+
implementation (with slow imports) speeds up the ``--help`` option.
45
"""
56

67
import logging
@@ -88,8 +89,8 @@ def _collect_type_info(root_path, *, ignore=(), cache=False):
8889
ignore : Sequence[str], optional
8990
Don't yield files matching these glob-like patterns. The pattern is
9091
interpreted relative to the root of the Python package unless it starts
91-
with "/". See :ref:`glob.translate(..., recursive=True, include_hidden=True)`
92-
for more details on the precise implementation.
92+
with "/". See :func:`glob.translate` with recursion and hidden files
93+
enabled for more details on the precise implementation.
9394
cache : bool, optional
9495
Cache collected types.
9596
@@ -140,6 +141,7 @@ def _collect_type_info(root_path, *, ignore=(), cache=False):
140141

141142
@contextmanager
142143
def _log_execution_time():
144+
"""Measure and log execution time of wrapped function or block."""
143145
start = time.time()
144146
try:
145147
yield
@@ -255,7 +257,8 @@ def generate_stubs(
255257
no_cache,
256258
verbosity,
257259
):
258-
"""
260+
"""Generate Python stub files (implementation of ``docstub run``).
261+
259262
Parameters
260263
----------
261264
root_path : Path

0 commit comments

Comments
 (0)