@@ -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
99132class _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