Skip to content

Commit d2bd432

Browse files
authored
sphinxdocs: add typedef directive for documenting user-defined types (#2300)
This adds support for documenting user-defined Starlark "types". Starlark doesn't have user-defined types as a first-class concept, but an equivalent can be done by using `struct` with lambdas and closures. On the documentation side, the structure of these objects can be shown by have a module-level struct with matching attributes. On the Sphinx side of things, this is simple to support (and the functionality was largely already there): it's just having a directive with other directives within it (this is the same way other languages handle it). On the Starlark side of things, its a bit more complicated. Stardoc can process a module-level struct, but essentially returns a list of `(dotted_name, object_proto)`, and it will only include object types it recognizes (e.g. functions, providers, rules, etc). To work within this limitation, the proto-to-markdown converter special cases the name "TYPEDEF" to indicate a typedef. Everything with the same prefix is then treated as a member of the typedef and nested within the generated typedef directive. Conveniently, because the "TYPEDEF" object is a function, it can then include that in the output and we get "class doc" functionality for free. This is mostly motivated by converting rules_testing to use sphinxdocs. While rules_python has a couple user-define types (e.g. the depset/runfiles/PyInfo builders), rules_testing has dozens of such types, which makes it untenable to hand-write docs describing them all. Today, rules_testing is already mostly following the format sphinxdocs proscribes to generate its at https://rules-testing.readthedocs.io/en/latest/api/index.html, and it's worked pretty well.
1 parent ced0c10 commit d2bd432

File tree

8 files changed

+360
-20
lines changed

8 files changed

+360
-20
lines changed

sphinxdocs/docs/sphinx-bzl.md

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ The documentation renders using RST notation (`.. directive::`), not
227227
MyST notation.
228228
:::
229229

230+
Directives can be nested, but [the inner directives must have **fewer** colons
231+
than outer
232+
directives](https://myst-parser.readthedocs.io/en/latest/syntax/roles-and-directives.html#nesting-directives).
233+
234+
230235
:::{rst:directive} .. bzl:currentfile:: file
231236

232237
This directive indicates the Bazel file that objects defined in the current
@@ -237,21 +242,87 @@ files, and `//foo:BUILD.bazel` for things in BUILD files.
237242
:::
238243

239244

240-
:::{rst:directive} .. bzl:target:: target
245+
:::::{rst:directive} .. bzl:target:: target
241246

242247
Documents a target. It takes no directive options. The format of `target`
243248
can either be a fully qualified label (`//foo:bar`), or the base target name
244249
relative to `{bzl:currentfile}`.
245250

246-
```
251+
````
247252
:::{bzl:target} //foo:target
248253
249254
My docs
250255
:::
251-
```
256+
````
257+
258+
:::::
252259

253260
:::{rst:directive} .. bzl:flag:: target
254261

255262
Documents a flag. It has the same format as `{bzl:target}`
256263
:::
257264

265+
::::::{rst:directive} .. bzl:typedef:: typename
266+
267+
Documents a user-defined structural "type". These are typically generated by
268+
the {obj}`sphinx_stardoc` rule after following [User-defined types] to create a
269+
struct with a `TYPEDEF` field, but can also be manually defined if there's
270+
no natural place for it in code, e.g. some ad-hoc structural type.
271+
272+
`````
273+
::::{bzl:typedef} Square
274+
Doc about Square
275+
276+
:::{bzl:field} width
277+
:type: int
278+
:::
279+
280+
:::{bzl:function} new(size)
281+
...
282+
:::
283+
284+
:::{bzl:function} area()
285+
...
286+
:::
287+
::::
288+
`````
289+
290+
Note that MyST requires the number of colons for the outer typedef directive
291+
to be greater than the inner directives. Otherwise, only the first nested
292+
directive is parsed as part of the typedef, but subsequent ones are not.
293+
::::::
294+
295+
:::::{rst:directive} .. bzl:field:: fieldname
296+
297+
Documents a field of an object. These are nested within some other directive,
298+
typically `{bzl:typedef}`
299+
300+
Directive options:
301+
* `:type:` specifies the type of the field
302+
303+
````
304+
:::{bzl:field} fieldname
305+
:type: int | None | str
306+
307+
Doc about field
308+
:::
309+
````
310+
:::::
311+
312+
:::::{rst:directive} .. bzl:provider-field:: fieldname
313+
314+
Documents a field of a provider. The directive itself is autogenerated by
315+
`sphinx_stardoc`, but the content is simply the documentation string specified
316+
in the provider's field.
317+
318+
Directive options:
319+
* `:type:` specifies the type of the field
320+
321+
````
322+
:::{bzl:provider-field} fieldname
323+
:type: depset[File] | None
324+
325+
Doc about the provider field
326+
:::
327+
````
328+
:::::

sphinxdocs/docs/starlark-docgen.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,90 @@ bzl_library(
7373
deps = ...
7474
)
7575
```
76+
77+
## User-defined types
78+
79+
While Starlark doesn't have user-defined types as a first-class concept, it's
80+
still possible to create such objects using `struct` and lambdas. For the
81+
purposes of documentation, they can be documented by creating a module-level
82+
`struct` with matching fields *and* also a field named `TYPEDEF`. When the
83+
`sphinx_stardoc` rule sees a struct with a `TYPEDEF` field, it generates doc
84+
using the {rst:directive}`bzl:typedef` directive and puts all the struct's fields
85+
within the typedef. The net result is the rendered docs look similar to how
86+
a class would be documented in other programming languages.
87+
88+
For example, a the Starlark implemenation of a `Square` object with a `area()`
89+
method would look like:
90+
91+
```
92+
93+
def _Square_typedef():
94+
"""A square with fixed size.
95+
96+
:::{field} width
97+
:type: int
98+
:::
99+
"""
100+
101+
def _Square_new(width):
102+
"""Creates a Square.
103+
104+
Args:
105+
width: {type}`int` width of square
106+
107+
Returns:
108+
{type}`Square`
109+
"""
110+
self = struct(
111+
area = lambda *a, **k: _Square_area(self, *a, **k),
112+
width = width
113+
)
114+
return self
115+
116+
def _Square_area(self, ):
117+
"""Tells the area of the square."""
118+
return self.width * self.width
119+
120+
Square = struct(
121+
TYPEDEF = _Square_typedef,
122+
new = _Square_new,
123+
area = _Square_area,
124+
)
125+
```
126+
127+
This will then genereate markdown that looks like:
128+
129+
```
130+
::::{bzl:typedef} Square
131+
A square with fixed size
132+
133+
:::{bzl:field} width
134+
:type: int
135+
:::
136+
:::{bzl:function} new()
137+
...args etc from _Square_new...
138+
:::
139+
:::{bzl:function} area()
140+
...args etc from _Square_area...
141+
:::
142+
::::
143+
```
144+
145+
Which renders as:
146+
147+
:::{bzl:currentfile} //example:square.bzl
148+
:::
149+
150+
::::{bzl:typedef} Square
151+
A square with fixed size
152+
153+
:::{bzl:field} width
154+
:type: int
155+
:::
156+
:::{bzl:function} new()
157+
...
158+
:::
159+
:::{bzl:function} area()
160+
...
161+
:::
162+
::::

sphinxdocs/private/proto_to_markdown.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ def __init__(
9696
self._module = module
9797
self._out_stream = out_stream
9898
self._public_load_path = public_load_path
99+
self._typedef_stack = []
100+
101+
def _get_colons(self):
102+
# There's a weird behavior where increasing colon indents doesn't
103+
# parse as nested objects correctly, so we have to reduce the
104+
# number of colons based on the indent level
105+
indent = 10 - len(self._typedef_stack)
106+
assert indent >= 0
107+
return ":::" + ":" * indent
99108

100109
def render(self):
101110
self._render_module(self._module)
@@ -115,11 +124,10 @@ def _render_module(self, module: stardoc_output_pb2.ModuleInfo):
115124
"\n\n",
116125
)
117126

118-
# Sort the objects by name
119127
objects = itertools.chain(
120128
((r.rule_name, r, self._render_rule) for r in module.rule_info),
121129
((p.provider_name, p, self._render_provider) for p in module.provider_info),
122-
((f.function_name, f, self._render_func) for f in module.func_info),
130+
((f.function_name, f, self._process_func_info) for f in module.func_info),
123131
((a.aspect_name, a, self._render_aspect) for a in module.aspect_info),
124132
(
125133
(m.extension_name, m, self._render_module_extension)
@@ -130,13 +138,31 @@ def _render_module(self, module: stardoc_output_pb2.ModuleInfo):
130138
for r in module.repository_rule_info
131139
),
132140
)
141+
# Sort by name, ignoring case. The `.TYPEDEF` string is removed so
142+
# that the .TYPEDEF entries come before what is in the typedef.
143+
objects = sorted(objects, key=lambda v: v[0].removesuffix(".TYPEDEF").lower())
133144

134-
objects = sorted(objects, key=lambda v: v[0].lower())
135-
136-
for _, obj, func in objects:
137-
func(obj)
145+
for name, obj, func in objects:
146+
self._process_object(name, obj, func)
138147
self._write("\n")
139148

149+
# Close any typedefs
150+
while self._typedef_stack:
151+
self._typedef_stack.pop()
152+
self._render_typedef_end()
153+
154+
def _process_object(self, name, obj, renderer):
155+
# The trailing doc is added to prevent matching a common prefix
156+
typedef_group = name.removesuffix(".TYPEDEF") + "."
157+
while self._typedef_stack and not typedef_group.startswith(
158+
self._typedef_stack[-1]
159+
):
160+
self._typedef_stack.pop()
161+
self._render_typedef_end()
162+
renderer(obj)
163+
if name.endswith(".TYPEDEF"):
164+
self._typedef_stack.append(typedef_group)
165+
140166
def _render_aspect(self, aspect: stardoc_output_pb2.AspectInfo):
141167
_sort_attributes_inplace(aspect.attribute)
142168
self._write("::::::{bzl:aspect} ", aspect.aspect_name, "\n\n")
@@ -242,12 +268,32 @@ def _rule_attr_type_string(self, attr: stardoc_output_pb2.AttributeInfo) -> str:
242268
# Rather than error, give some somewhat understandable value.
243269
return _AttributeType.Name(attr.type)
244270

271+
def _process_func_info(self, func):
272+
if func.function_name.endswith(".TYPEDEF"):
273+
self._render_typedef_start(func)
274+
else:
275+
self._render_func(func)
276+
277+
def _render_typedef_start(self, func):
278+
self._write(
279+
self._get_colons(),
280+
"{bzl:typedef} ",
281+
func.function_name.removesuffix(".TYPEDEF"),
282+
"\n",
283+
)
284+
if func.doc_string:
285+
self._write(func.doc_string.strip(), "\n")
286+
287+
def _render_typedef_end(self):
288+
self._write(self._get_colons(), "\n\n")
289+
245290
def _render_func(self, func: stardoc_output_pb2.StarlarkFunctionInfo):
246-
self._write("::::::{bzl:function} ")
291+
self._write(self._get_colons(), "{bzl:function} ")
247292

248293
parameters = self._render_func_signature(func)
249294

250-
self._write(func.doc_string.strip(), "\n\n")
295+
if doc_string := func.doc_string.strip():
296+
self._write(doc_string, "\n\n")
251297

252298
if parameters:
253299
for param in parameters:
@@ -268,10 +314,13 @@ def _render_func(self, func: stardoc_output_pb2.StarlarkFunctionInfo):
268314
self._write(":::::{deprecated}: unknown\n")
269315
self._write(" ", _indent_block_text(func.deprecated.doc_string), "\n")
270316
self._write(":::::\n")
271-
self._write("::::::\n")
317+
self._write(self._get_colons(), "\n")
272318

273319
def _render_func_signature(self, func):
274-
self._write(f"{func.function_name}(")
320+
func_name = func.function_name
321+
if self._typedef_stack:
322+
func_name = func.function_name.removeprefix(self._typedef_stack[-1])
323+
self._write(f"{func_name}(")
275324
# TODO: Have an "is method" directive in the docstring to decide if
276325
# the self parameter should be removed.
277326
parameters = [param for param in func.parameter if param.name != "self"]

0 commit comments

Comments
 (0)