# IfcOpenShell - IFC toolkit and geometry engine # Copyright (C) 2021 Thomas Krijnen # # This file is part of IfcOpenShell. # # IfcOpenShell is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # IfcOpenShell is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . """Script to ensure ifcopenshell_wrapper.pyi and ifcopenshell_wrapper.py work in sync. Things we do check: - all symbols from the wrapper present in the stub and vice versa - functions and methods signatures - read-only and settable properties, staticmethods - class hierarchy """ import ast import difflib from pathlib import Path from typing import Union from typing_extensions import assert_never def format_diff(lines: list[str]) -> None: RED = "\033[91m" GREEN = "\033[92m" CYAN = "\033[96m" RESET = "\033[0m" for line in lines: if line.startswith("+") and not line.startswith("+++"): print(f"{GREEN}{line}{RESET}") elif line.startswith("-") and not line.startswith("---"): print(f"{RED}{line}{RESET}") elif line.startswith("@@"): print(f"{CYAN}{line}{RESET}") else: print(line) SubnameType = Union[str, tuple[str, str]] def get_function_node_name(node: ast.FunctionDef) -> Union[SubnameType, None]: """ :return: Function node name as ``SubnameType`` or ``None``, if function wasn't processed and can be skipped. """ node_name = node.name is_init = node_name == "__init__" if node_name.startswith("_") and node_name not in ("_is",) and not is_init: return None arg_nodes = node.args.args defaults = [None] * (len(arg_nodes) - len(node.args.defaults)) + node.args.defaults args: list[str] = [] for arg, default in zip(arg_nodes, defaults): if default is None: args.append(arg.arg) else: args.append(f"{arg.arg}={ast.unparse(default)}") if arg := node.args.vararg: args.append(f"*{arg.arg}") if arg := node.args.kwarg: args.append(f"**{arg.arg}") # Skip non-informative constructors. if is_init and args == ["self"]: return None node_name = f"def {node.name}" node_name = f"{node_name}({', '.join(args)}): ..." decorators = tuple(f"@{d.id}" for d in node.decorator_list if isinstance(d, ast.Name)) if len(decorators) == 1: return decorators + (node_name,) return node_name def get_names_tree_lines(tree: ast.Module) -> list[str]: # Get class tree. names_tree: dict[str, set[SubnameType]] = {} for node in tree.body: subnames: set[SubnameType] = set() node_name = None if isinstance(node, ast.ClassDef): # Skip `object_` as it's just a reference to `object`, # which is implied by default. bases = [b.id for b in node.bases if isinstance(b, ast.Name) and b.id not in ("_object", "object")] bases_str = f"({', '.join(bases)})" if bases else "" node_name = f"class {node.name}{bases_str}:" for subnode in node.body: subname = None if isinstance(subnode, ast.AnnAssign): target = subnode.target assert isinstance(target, ast.Name) subname = target.id elif isinstance(subnode, ast.FunctionDef): subname = get_function_node_name(subnode) elif isinstance(subnode, ast.Assign): targets = subnode.targets if not len(targets) == 1 or not isinstance(target := targets[0], ast.Name): continue subname_ = target.id if subname_.startswith(("_", "thisown")): continue value = subnode.value if not isinstance(value, ast.Call): subname = subname_ else: # Catching wrappers like: # - `matrix = property(matrix_getter)` # - `matrix = property(matrix_getter, matrix_setter)` # - `operation_str = staticmethod(operation_str)` func = value.func if not isinstance(func, ast.Name) or ((func_id := func.id) not in ("property", "staticmethod")): continue args = [arg.id for arg in value.args if isinstance(arg, ast.Name)] len_args = len(args) if len_args in (1, 2): def find_method_by_name(name: str) -> Union[str, None]: function_def = f"def {name}(" return next( ( func_ for func_ in subnames if isinstance(func_, str) and func_.startswith(function_def) ), None, ) # Use `set` for cases like `description = property(description, description)`. wrapped_function = None for arg in set(args): assert (wrapped_function := find_method_by_name(arg)) subnames.remove(wrapped_function) # TODO: sort it out in wrapper.py # There's one annoying case in Element.product # when property is overriding existing function, without using it. # We should probably just exclude that function from the wrapper. overridden_name = find_method_by_name(subname_) if overridden_name: subnames.remove(overridden_name) if len_args == 2: # Has both getter and setter, can be defined as a simple attribute. subname = subname_ elif len_args == 1: if func_id == "property": # Has just getter, read-only, need to define it using a wrapper. subname = (f"@{func_id}", f"def {subname_}(self): ...") elif func_id == "staticmethod": assert wrapped_function is not None subname = (f"@{func_id}", f"def {subname_}({wrapped_function.split('(')[1]}") else: assert_never(func_id) else: assert_never(len_args) else: attr_args = [ arg for arg in value.args if isinstance(arg, ast.Attribute) and isinstance(arg.value, ast.Name) and arg.value.id == "_ifcopenshell_wrapper" ] assert len(attr_args) == 2 subname = subname_ if subname is not None: subnames.add(subname) if not subnames: node_name += " ..." elif isinstance(node, ast.FunctionDef): if node.name.startswith("_"): continue node_name = get_function_node_name(node) assert isinstance(node_name, str) elif isinstance(node, ast.Assign): targets = node.targets if not len(targets) == 1 or not isinstance(target := targets[0], ast.Name): continue node_name = target.id elif isinstance(node, ast.AnnAssign): target = node.target assert isinstance(target, ast.Name) node_name = target.id if node_name is not None: names_tree[node_name] = subnames # Convert names tree to lines. lines: list[str] = [] indent = " " * 4 def subname_sort(subname: SubnameType) -> str: if isinstance(subname, str): if subname.startswith("def "): return subname.split("(")[0].removeprefix("def ") return subname return subname_sort(subname[1]) for name, subnames in sorted(names_tree.items(), key=lambda x: x[0]): lines.append(name) for subname in sorted(subnames, key=subname_sort): subitems = (subname,) if isinstance(subname, str) else subname for item in subitems: lines.append(f"{indent}{item}") return lines def main() -> None: package = Path(__file__).parent.parent.parent stub_path = package / "ifcopenshell_wrapper.pyi" wrapper_path = package / "ifcopenshell_wrapper.py" # Parse files stub_tree = ast.parse(stub_path.read_text()) wrapper_tree = ast.parse(wrapper_path.read_text()) # Extract class names stub_classes = get_names_tree_lines(stub_tree) wrapper_classes = get_names_tree_lines(wrapper_tree) # Use difflib to create a unified diff of class names diff = difflib.unified_diff( stub_classes, wrapper_classes, fromfile="stub.pyi classes", tofile="wrapper.py classes", lineterm="", n=10, ) diff = list(diff) format_diff(diff) diff_no_header = diff[2:] added = len([l for l in diff_no_header if l.startswith("+")]) removed = len([l for l in diff_no_header if l.startswith("-")]) if added or removed: print(f"Added lines: {added}") print(f"Removed lines: {removed}") raise Exception("Found discrepancies between stub and wrapper.") else: print(f"All good, no discrepancies between stub and wrapper. 🎉🎉") if __name__ == "__main__": main()