diff --git a/src/bonsai/bonsai/bim/module/patch/prop.py b/src/bonsai/bonsai/bim/module/patch/prop.py index e12da9e722..3d372e5036 100644 --- a/src/bonsai/bonsai/bim/module/patch/prop.py +++ b/src/bonsai/bonsai/bim/module/patch/prop.py @@ -36,7 +36,7 @@ from bpy.props import ( from typing import TYPE_CHECKING, Literal, Union -ifcpatchrecipes_enum = [] +ifcpatchrecipes_enum: list[tuple[str, str, str]] = [] def purge(): @@ -57,7 +57,18 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context) if f == "__init__": continue 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]) return ifcpatchrecipes_enum diff --git a/src/ifcpatch/ifcpatch/__init__.py b/src/ifcpatch/ifcpatch/__init__.py index e949379a03..8303e90a3a 100644 --- a/src/ifcpatch/ifcpatch/__init__.py +++ b/src/ifcpatch/ifcpatch/__init__.py @@ -28,6 +28,7 @@ import inspect import collections import importlib import importlib.util +import re from typing import Union, Iterable, Optional, Any, TypedDict, Literal, Sequence from typing_extensions import NotRequired @@ -133,7 +134,7 @@ def write(output: Union[ifcopenshell.file, str], filepath: str) -> None: def extract_docs( 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 :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)}") -def _extract_docs(cls: type, method_name: str, boilerplate_args: Union[Iterable[str], None]) -> dict[str, Any]: - inputs = collections.OrderedDict() +class PatcherDoc(TypedDict): + 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) - docs: dict[str, Any] = {"class": cls} if boilerplate_args is None: 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(): if name == "self" or name in boilerplate_args: continue - inputs[name] = {"name": name} + input_doc: InputDoc = {"name": name} + inputs[name] = input_doc if isinstance(parameter.default, (str, float, int, bool)): - inputs[name]["default"] = parameter.default + input_doc["default"] = parameter.default # Parse data from type hints. 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. description = "" - doc = method.__doc__ - if doc is not None: - 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 + # `getdoc` instead of `__doc__` for sane indentation. + doc = inspect.getdoc(method) - docs["description"] = description.strip() - docs["inputs"] = inputs + def is_valid_param_name(param_name: str) -> bool: + 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 + + +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