Skip to content

Commit 2be8123

Browse files
Docstrings for codegen (#129)
* merge main * bump version * render docs
1 parent 3c791c1 commit 2be8123

12 files changed

Lines changed: 3718 additions & 594 deletions

File tree

docs/_inv/python_objects.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/_variables.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version: 520.8.0
1+
version: 520.9.0

docs/assets.qmd

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,26 @@ generate_asset_modules(
7171
Because asset names repeat across editors, splitting also keeps the generated
7272
class names collision-free where a single mixed module would silently shadow
7373
one tree type's class with another's.
74+
=======
75+
### Docstrings and menu types
76+
77+
By default the generated classes carry numpy-style docstrings built from the
78+
asset's own interface — the group description, then `Parameters`, `Inputs` and
79+
`Outputs` sections using each socket's tooltip — so editors show documentation
80+
next to the type hints. Menu sockets are narrowed to the items they actually
81+
offer:
82+
83+
```{python}
84+
# | eval: false
85+
def __init__(
86+
self,
87+
geometry: InputGeometry = None,
88+
shape: InputMenu | Literal["Line", "Circle", "Curve", "Transform"] = "Line",
89+
...
90+
```
91+
92+
Pass `docstrings=False` (or `--no-docstrings` on the command line) for a terser
93+
module without the class docstrings.
7494

7595
The generated module imports from `nodebpy` and looks like any other node
7696
module, so import and use it directly:

docs/changelog.qmd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ python -m nodebpy.assets -b my_assets.blend -o my_addon/nodes/
4242

4343
### Enhancements
4444
- Changed the linking of asset node groups for the `_AssetGroupMixin` to be 'linked & packed' by default
45+
- Generated asset classes now carry numpy-style docstrings (description, `Parameters`, `Inputs`, `Outputs`) built from the asset's own socket tooltips, so editors show documentation alongside the type hints. Pass `docstrings=False` to `generate_asset_api` (or `--no-docstrings` to `python -m nodebpy.assets`) for the terser output.
46+
- Menu sockets on generated asset classes are typed with the items they actually offer — `shape: InputMenu | Literal["Line", "Circle", "Curve", "Transform"]` — matching how menu sockets are already typed on the built-in nodes.
4547

4648
## v520.4.0 - 2026-06-19
4749

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "nodebpy"
3-
version = "520.8.0"
3+
version = "520.9.0"
44
description = "Build nodes trees in Blender more elegantly with code"
55
readme = "README.md"
66
authors = [

src/nodebpy/assets/__main__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232

3333
def generate_essentials(
34-
nodes_dir: Path, nodebpy_pkg: str = ".."
34+
nodes_dir: Path, nodebpy_pkg: str = "..", docstrings: bool = True
3535
) -> dict[str, list[str]]:
3636
"""Generate the bundled-essentials asset modules into
3737
``<nodes_dir>/<tree>/assets.py``; returns the class names written per tree
@@ -50,6 +50,7 @@ def generate_essentials(
5050
libraries,
5151
Path(nodes_dir) / tree / "assets.py",
5252
nodebpy_pkg=nodebpy_pkg,
53+
docstrings=docstrings,
5354
)
5455
written[tree] = names
5556
print(f" nodes/{tree}/assets.py: {len(names)} asset classes")
@@ -86,6 +87,15 @@ def parse_args() -> argparse.Namespace:
8687
"package — e.g. '..lib.nodebpy'."
8788
),
8889
)
90+
parser.add_argument(
91+
"--no-docstrings",
92+
dest="docstrings",
93+
action="store_false",
94+
help=(
95+
"Skip the numpy-style class docstrings (description, Parameters, "
96+
"Inputs, Outputs) and emit a terser module."
97+
),
98+
)
8999
return parser.parse_args()
90100

91101

@@ -117,10 +127,13 @@ def main() -> None: # pragma: no cover - CLI wrapper
117127
[PackageLibrary(str(output), relative)],
118128
output,
119129
nodebpy_pkg=args.nodebpy_pkg,
130+
docstrings=args.docstrings,
120131
)
121132
return
122133

123-
generate_essentials(Path(__file__).parent.parent / "nodes")
134+
generate_essentials(
135+
Path(__file__).parent.parent / "nodes", docstrings=args.docstrings
136+
)
124137

125138

126139
if __name__ == "__main__": # pragma: no cover

src/nodebpy/assets/_codegen.py

Lines changed: 131 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,39 @@ def _format_default(socket: bpy.types.NodeSocket) -> str:
9595
return "None"
9696

9797

98+
def _clean_doc(text: str) -> str:
99+
"""Make ``text`` safe to drop inside a ``\"\"\"\"\"\"`` docstring."""
100+
text = " ".join(text.split())
101+
text = text.replace('"""', "'''")
102+
return text.rstrip("\\").rstrip()
103+
104+
105+
def _quote(text: str) -> str:
106+
"""``text`` as a double-quoted Python string literal."""
107+
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
108+
109+
110+
def _menu_items(socket) -> tuple[str, ...]:
111+
"""The items a menu socket accepts, in order.
112+
113+
A menu socket's items come from the Menu Switch node that defines them and
114+
aren't readable from the socket's RNA enum, but assigning an impossible
115+
value makes Blender list them in the ``TypeError`` — the same trick as
116+
``gen.introspect._collect_socket_menu_items`` (duplicated rather than
117+
imported, since ``gen`` is a build tool and isn't shipped with the package).
118+
"""
119+
if getattr(socket, "type", "") != "MENU" or not socket.default_value:
120+
return ()
121+
try:
122+
socket.default_value = "X" * 100
123+
except TypeError as error:
124+
_, _, listed = str(error).partition("not found in ")
125+
items = (item.strip("()'\" ") for item in listed.split(", "))
126+
return tuple(item for item in items if item)
127+
# A menu that accepted the impossible value tells us nothing about its items.
128+
return ()
129+
130+
98131
@dataclass
99132
class _Socket:
100133
name: str
@@ -103,6 +136,27 @@ class _Socket:
103136
input_type: str # e.g. "InputGeometry"
104137
default: str # source for the default value
105138
attr: str # normalized accessor/param name
139+
description: str = "" # interface tooltip, if the asset author set one
140+
menu_items: tuple[str, ...] = () # menu sockets only: the selectable items
141+
142+
@property
143+
def doc(self) -> str:
144+
"""Documentation line for this socket — its tooltip, else its name."""
145+
return _clean_doc(self.description or self.name)
146+
147+
@property
148+
def param_type(self) -> str:
149+
"""Type hint for the ``__init__`` parameter.
150+
151+
A menu socket is narrowed to its own items so editors offer them for
152+
completion, while still accepting a linked ``MenuSocket``.
153+
"""
154+
if not self.menu_items:
155+
return self.input_type
156+
# Double-quoted to match the formatted source (ruff reformats the code
157+
# but not the docstring copy of the same annotation).
158+
literals = ", ".join(_quote(item) for item in self.menu_items)
159+
return f"{self.input_type} | Literal[{literals}]"
106160

107161

108162
@dataclass
@@ -116,7 +170,17 @@ class _AssetClass:
116170
outputs: list[_Socket]
117171

118172

119-
def _collect(sockets) -> list[_Socket]:
173+
def _collect(
174+
sockets,
175+
descriptions: dict[str, str] | None = None,
176+
menus: bool = False,
177+
) -> list[_Socket]:
178+
"""Introspect ``sockets`` into records.
179+
180+
``menus`` resolves menu sockets to their items — only worth doing for the
181+
group's *inputs*, whose parameters are typed from them.
182+
"""
183+
descriptions = descriptions or {}
120184
raw = [
121185
s
122186
for s in sockets
@@ -129,6 +193,7 @@ def _collect(sockets) -> list[_Socket]:
129193
out: list[_Socket] = []
130194
for s in raw:
131195
socket_class, input_type = _socket_types(type(s).__name__)
196+
menu_items = _menu_items(s) if menus else ()
132197
norm_name = normalize_name(s.name)
133198
attr = (
134199
norm_name if name_counts[norm_name] == 1 else normalize_name(s.identifier)
@@ -141,6 +206,8 @@ def _collect(sockets) -> list[_Socket]:
141206
input_type=input_type,
142207
default=_format_default(s),
143208
attr=attr,
209+
description=descriptions.get(s.identifier, ""),
210+
menu_items=menu_items,
144211
)
145212
)
146213
return out
@@ -179,15 +246,23 @@ def _introspect(library: AssetLibrary, names: set[str] | None) -> list[_AssetCla
179246
}[group.bl_idname]
180247
node = host.nodes.new(node_type)
181248
node.node_tree = group # ty: ignore[unresolved-attribute]
249+
# Tooltips live on the tree *interface* items, not on the node's
250+
# sockets — collect them by identifier so the generated docstrings
251+
# can use the asset author's own wording.
252+
descriptions = {
253+
item.identifier: item.description or ""
254+
for item in group.interface.items_tree
255+
if item.item_type == "SOCKET"
256+
}
182257
classes.append(
183258
_AssetClass(
184259
class_name=_class_name(name),
185260
asset_name=name,
186261
description=(group.description or name).strip(),
187262
library_source=library_source,
188263
tree_idname=group.bl_idname,
189-
inputs=_collect(node.inputs),
190-
outputs=_collect(node.outputs),
264+
inputs=_collect(node.inputs, descriptions, menus=True),
265+
outputs=_collect(node.outputs, descriptions),
191266
)
192267
)
193268
finally:
@@ -210,33 +285,58 @@ def _library_source(library: AssetLibrary) -> str:
210285
raise TypeError(f"Cannot serialise asset library: {library!r}")
211286

212287

213-
def _accessor(sockets: list[_Socket], kind: str) -> str:
288+
def _accessor(sockets: list[_Socket], kind: str, docstrings: bool) -> str:
214289
if not sockets:
215290
return f" class {kind}(SocketAccessor):\n pass"
216291
lines = [f" class {kind}(SocketAccessor):"]
217292
for s in sockets:
218293
lines.append(f" {s.attr}: {s.socket_class}")
219-
doc = s.name
294+
doc = s.doc if docstrings else _clean_doc(s.name)
220295
if doc and doc != s.attr:
221296
lines.append(f' """{doc}"""')
222297
return "\n".join(lines)
223298

224299

225-
def _render_class(cls: _AssetClass) -> str:
300+
def _class_docstring(cls: _AssetClass) -> str:
301+
"""A numpy-style docstring for ``cls``, matching the built-in node classes."""
302+
lines = [_clean_doc(cls.description), ""]
303+
if cls.inputs:
304+
lines += ["Parameters", "----------"]
305+
for s in cls.inputs:
306+
lines += [f"{s.attr} : {s.param_type}", f" {s.doc}"]
307+
lines.append("")
308+
lines += ["Inputs", "------"]
309+
for s in cls.inputs:
310+
lines += [f"i.{s.attr} : {s.socket_class}", f" {s.doc}"]
311+
lines.append("")
312+
if cls.outputs:
313+
lines += ["Outputs", "-------"]
314+
for s in cls.outputs:
315+
lines += [f"o.{s.attr} : {s.socket_class}", f" {s.doc}"]
316+
# Indent to the class body, leaving blank separator lines truly blank so the
317+
# module needs no formatter pass to be clean.
318+
body = "\n".join(f" {line}" if line else "" for line in lines).strip("\n")
319+
return f'"""\n{body}\n """'
320+
321+
322+
def _render_class(cls: _AssetClass, docstrings: bool = False) -> str:
226323
base = asset_group_base(cls.tree_idname).__name__
227-
inputs_cls = _accessor(cls.inputs, "_Inputs")
228-
outputs_cls = _accessor(cls.outputs, "_Outputs")
324+
docstring = (
325+
_class_docstring(cls) if docstrings else f'"""{_clean_doc(cls.description)}"""'
326+
)
327+
inputs_cls = _accessor(cls.inputs, "_Inputs", docstrings)
328+
outputs_cls = _accessor(cls.outputs, "_Outputs", docstrings)
229329

230-
params = [f"{s.attr}: {s.input_type} = {s.default}" for s in cls.inputs]
330+
params = [f"{s.attr}: {s.param_type} = {s.default}" for s in cls.inputs]
231331
signature = (
232332
"(\n self,\n " + ",\n ".join(params) + ",\n )"
233333
if params
234334
else "(self)"
235335
)
236336
key_args = ", ".join(f'"{s.identifier}": {s.attr}' for s in cls.inputs)
237337

238-
return f'''class {cls.class_name}({base}):
239-
"""{cls.description}"""
338+
return f"""class {cls.class_name}({base}):
339+
{docstring}
240340
241341
_name = {cls.asset_name!r}
242342
_asset_name = {cls.asset_name!r}
@@ -254,10 +354,14 @@ def o(self) -> _Outputs: ...
254354
255355
def __init__{signature}:
256356
super().__init__(**{{{key_args}}})
257-
'''
357+
"""
258358

259359

260-
def _render_module(classes: list[_AssetClass], nodebpy_pkg: str = "nodebpy") -> str:
360+
def _render_module(
361+
classes: list[_AssetClass],
362+
nodebpy_pkg: str = "nodebpy",
363+
docstrings: bool = False,
364+
) -> str:
261365
socket_classes = sorted(
262366
{s.socket_class for c in classes for s in c.inputs + c.outputs}
263367
)
@@ -276,9 +380,13 @@ def _render_module(classes: list[_AssetClass], nodebpy_pkg: str = "nodebpy") ->
276380
set(bases) | set(libraries) | {"SocketAccessor"} | set(socket_classes)
277381
)
278382

383+
typing_imports = ["TYPE_CHECKING"]
384+
if any(s.menu_items for c in classes for s in c.inputs):
385+
typing_imports.append("Literal")
386+
279387
lines = [
280388
"# Auto-generated by nodebpy.assets.generate_asset_api — do not edit manually.",
281-
"from typing import TYPE_CHECKING",
389+
f"from typing import {', '.join(typing_imports)}",
282390
"",
283391
f"from {nodebpy_pkg}.builder import (\n {',\n '.join(builder_imports)},\n)",
284392
f"from {nodebpy_pkg}.types import (\n {',\n '.join(input_types)},\n)"
@@ -287,7 +395,7 @@ def _render_module(classes: list[_AssetClass], nodebpy_pkg: str = "nodebpy") ->
287395
]
288396
header = "\n".join(line for line in lines if line) + "\n\n\n"
289397
ordered = sorted(classes, key=lambda c: c.class_name)
290-
body = "\n\n".join(_render_class(c) for c in ordered)
398+
body = "\n\n".join(_render_class(c, docstrings) for c in ordered)
291399
all_names = ",\n ".join(f'"{c.class_name}"' for c in ordered)
292400
footer = (
293401
f"\n\n__all__ = (\n {all_names},\n)\n" if ordered else "\n__all__ = ()\n"
@@ -301,6 +409,7 @@ def generate_asset_api(
301409
*,
302410
names: set[str] | None = None,
303411
nodebpy_pkg: str = "nodebpy",
412+
docstrings: bool = True,
304413
) -> list[str]:
305414
"""Generate typed asset classes for ``libraries`` into ``output_path``.
306415
@@ -321,6 +430,11 @@ def generate_asset_api(
321430
pass the path that reaches it *relative to the generated module's
322431
package* — e.g. ``"..vendor.nodebpy"`` — so the emitted imports stay
323432
relative to the install/vendor location.
433+
docstrings:
434+
Emit numpy-style class docstrings (description, ``Parameters``,
435+
``Inputs``, ``Outputs``) using the asset's own socket tooltips, so
436+
editors show documentation alongside the type hints. Defaults to
437+
``True``; pass ``False`` for a terser module.
324438
325439
Returns the list of generated class names.
326440
"""
@@ -334,7 +448,8 @@ def generate_asset_api(
334448
output_path = Path(output_path)
335449
output_path.parent.mkdir(parents=True, exist_ok=True)
336450
output_path.write_text(
337-
_render_module(classes, nodebpy_pkg=nodebpy_pkg), encoding="utf-8"
451+
_render_module(classes, nodebpy_pkg=nodebpy_pkg, docstrings=docstrings),
452+
encoding="utf-8",
338453
)
339454
return [c.class_name for c in classes]
340455

0 commit comments

Comments
 (0)