Bonsai - ifcpatch description to include arguments descriptions

Also fix a bug with broken patch descriptions if some argument had more than one line for the description.

Example - https://i.imgur.com/1syL4gJ.png
Notcied working on #6227
This commit is contained in:
Andrej730
2025-02-27 17:37:32 +05:00
parent 497542d7a0
commit 0bd608b6cb
2 changed files with 145 additions and 31 deletions
+13 -2
View File
@@ -36,7 +36,7 @@ from bpy.props import (
from typing import TYPE_CHECKING, Literal, Union from typing import TYPE_CHECKING, Literal, Union
ifcpatchrecipes_enum = [] ifcpatchrecipes_enum: list[tuple[str, str, str]] = []
def purge(): def purge():
@@ -57,7 +57,18 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context)
if f == "__init__": if f == "__init__":
continue continue
docs = ifcpatch.extract_docs(f, "Patcher", "__init__", ("src", "file", "logger", "args")) docs = ifcpatch.extract_docs(f, "Patcher", "__init__", ("src", "file", "logger", "args"))
ifcpatchrecipes_enum.append((f, f, docs.get("description", "") if docs else "")) if docs is None:
description = ""
else:
description = docs["description"]
inputs = docs["inputs"]
if inputs:
if description:
description += "\n\n"
description += "Parameters:"
for param, input_data in inputs.items():
description += f"\n\n- {param}: {input_data['description']}"
ifcpatchrecipes_enum.append((f, f, description))
ifcpatchrecipes_enum.sort(key=lambda x: x[0]) ifcpatchrecipes_enum.sort(key=lambda x: x[0])
return ifcpatchrecipes_enum return ifcpatchrecipes_enum
+132 -29
View File
@@ -28,6 +28,7 @@ import inspect
import collections import collections
import importlib import importlib
import importlib.util import importlib.util
import re
from typing import Union, Iterable, Optional, Any, TypedDict, Literal, Sequence from typing import Union, Iterable, Optional, Any, TypedDict, Literal, Sequence
from typing_extensions import NotRequired from typing_extensions import NotRequired
@@ -133,7 +134,7 @@ def write(output: Union[ifcopenshell.file, str], filepath: str) -> None:
def extract_docs( def extract_docs(
submodule_name: str, cls_name: str, method_name: str = "__init__", boilerplate_args: Optional[Iterable[str]] = None submodule_name: str, cls_name: str, method_name: str = "__init__", boilerplate_args: Optional[Iterable[str]] = None
) -> Union[dict[str, Any], None]: ) -> Union["PatcherDoc", None]:
"""Extract class docstrings and method arguments """Extract class docstrings and method arguments
:param submodule_name: Submodule from which to extract the class :param submodule_name: Submodule from which to extract the class
@@ -155,10 +156,26 @@ def extract_docs(
print(f"Error : IFCPatch {str(submodule)} could not load because : {str(e)}") print(f"Error : IFCPatch {str(submodule)} could not load because : {str(e)}")
def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Iterable[str], None]) -> dict[str, Any]: class PatcherDoc(TypedDict):
inputs = collections.OrderedDict() class_: type
description: str
output: Union[str, None]
inputs: dict[str, "InputDoc"]
class InputDoc(TypedDict):
name: str
description: str
type: Union[str, list[str]]
default: NotRequired[Any]
generic_type: NotRequired[str]
enum_items: NotRequired[list[str]]
filter_glob: NotRequired[str]
def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Iterable[str], None]) -> PatcherDoc:
inputs: dict[str, InputDoc] = {}
method = getattr(cls, method_name) method = getattr(cls, method_name)
docs: dict[str, Any] = {"class": cls}
if boilerplate_args is None: if boilerplate_args is None:
boilerplate_args = [] boilerplate_args = []
@@ -166,9 +183,10 @@ def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Iterable[
for name, parameter in signature.parameters.items(): for name, parameter in signature.parameters.items():
if name == "self" or name in boilerplate_args: if name == "self" or name in boilerplate_args:
continue continue
inputs[name] = {"name": name} input_doc: InputDoc = {"name": name}
inputs[name] = input_doc
if isinstance(parameter.default, (str, float, int, bool)): if isinstance(parameter.default, (str, float, int, bool)):
inputs[name]["default"] = parameter.default input_doc["default"] = parameter.default
# Parse data from type hints. # Parse data from type hints.
type_hints = typing.get_type_hints(method) type_hints = typing.get_type_hints(method)
@@ -193,28 +211,113 @@ def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Iterable[
# Parse the docstring. # Parse the docstring.
description = "" description = ""
doc = method.__doc__ # `getdoc` instead of `__doc__` for sane indentation.
if doc is not None: doc = inspect.getdoc(method)
for i, line in enumerate(doc.split("\n")):
line = line.strip()
if i == 0:
docs["name"] = line
elif line.startswith(":return:"):
docs["output"] = {"name": line.split(":")[2].strip(), "description": line.split(":")[3].strip()}
elif line.startswith(":param"):
param_name = line.split(":")[1].strip().replace("param ", "")
if param_name in inputs:
inputs[param_name]["description"] = line.split(":")[2].strip()
# :filter_glob is our special doc-tag.
elif line.startswith(":filter_glob"):
param_name = line.split(":")[1].strip().replace("filter_glob ", "")
if param_name in inputs:
inputs[param_name]["filter_glob"] = line.split(":")[2].strip()
elif i == 2:
description += line
elif i > 2:
description += "\n" + line
docs["description"] = description.strip() def is_valid_param_name(param_name: str) -> bool:
docs["inputs"] = inputs if param_name not in inputs:
print(
f"WARNING. Unexpected param name '{param_name}' in {cls.__name__} docstring (missing from signature)."
)
return False
return True
if doc is None:
doc_description = ""
doc_output = None
else:
docstring_data = parse_docstring(doc)
doc_description = docstring_data["description"]
doc_output = docstring_data["output"]
for param_name in docstring_data["param"]:
if not is_valid_param_name(param_name):
continue
inputs[param_name]["description"] = docstring_data["param"][param_name]
for param_name in docstring_data["filter_glob"]:
if not is_valid_param_name(param_name):
continue
inputs[param_name]["filter_glob"] = docstring_data["filter_glob"][param_name]
for param_name in inputs:
if "description" not in inputs[param_name]:
inputs[param_name]["description"] = "Undocumented"
docs = PatcherDoc(
class_=cls,
description=doc_description,
output=doc_output,
inputs=inputs,
)
return docs return docs
class DocstringData(TypedDict):
name: str
description: str
param: dict[str, str]
filter_glob: dict[str, str]
output: Union[str, None]
def parse_docstring(docstring: str) -> DocstringData:
# Keep left indentation to recognize the sections.
lines = docstring.split("\n")
result = DocstringData(
name=lines[0].strip(),
description="",
param={},
filter_glob={},
output=None,
)
current_section = None
last_param = None
PREFIXES = ("param", "filter_glob")
for line in lines[1:]:
if line.startswith(":"):
line = line[1:]
if line.startswith(PREFIXES):
prefix = line.split(" ")[0]
current_section = prefix
match_ = re.match(rf"{prefix}\s+(\w+):\s+(.*)", line)
assert match_, f"Invalid line: '{line}'."
param_name, param_desc = match_.groups()
result[prefix][param_name] = param_desc
last_param = param_name
continue
elif line.startswith("return:"):
current_section = "output"
match_ = re.match(r"return:\s+(.*)", line)
assert match_
result["output"] = match_.groups()[0]
continue
elif line.startswith("type"):
# Ignore types in favor of signature annotations.
continue
elif line.startswith("Example:"):
# Ignore code example at the end of the docstring.
break
# Multiline sections start with indentation.
if line.startswith(" ") and current_section:
line = line.lstrip()
if current_section == "output":
assert result["output"]
result["output"] += f"\n{line}"
elif current_section in PREFIXES:
assert last_param
result[current_section][last_param] += f"\n{line}"
continue
line = line.lstrip()
result["description"] += f"\n{line}"
current_section = None
last_param = None
result["description"] = result["description"].strip()
return result