ifcwrap: build the Python wrapper with SWIG -fastproxy -fastdispatch

-fastproxy binds each wrapped method directly to its compiled function
instead of going through a generated Python def, and -fastdispatch
shortens overload dispatch. Reading is_a(), id() and an attribute of
every one of TXG's 918k instances takes 2.15 s instead of 2.36 s (-8%);
the tight loop over 133 walls reading GlobalId, Name and is_a() twenty
times over 9 ms instead of 11 ms.

validate_stub compared the stub against the wrapper's def signatures by
parsing both files; a fast-proxy wrapper has assignments instead of
defs. It now rebuilds the signature from the compiled function's
autodoc docstring (the C++ prototype) with the rule SWIG itself uses:
one prototype whose defaults are Python literals becomes named
parameters, several prototypes or a non-literal default such as an enum
become *args. Checked against every def of a wrapper built without
-fastproxy: 773 of 773 signatures rebuild identically. The autodoc
feature moves above the SWIG library includes so the iterator and
container classes carry prototypes too.

This commit was written by an AI coding tool and has not been verified by
a human.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wcN7XquTfUi4vsKQ4KchL
This commit is contained in:
Dion Moult
2026-09-14 09:10:47 +10:00
parent 43686bde85
commit 1d950365a4
3 changed files with 110 additions and 3 deletions
@@ -27,6 +27,9 @@ Things we do check:
import ast
import difflib
import importlib
import re
import types
from pathlib import Path
from typing import Union
@@ -52,6 +55,79 @@ def format_diff(lines: list[str]) -> None:
SubnameType = Union[str, tuple[str, str]]
def _wrapper_function_doc(name: str) -> Union[str, None]:
"""Docstring of a function of the compiled module, ``_ifcopenshell_wrapper.<name>``, or ``None``.
A wrapper built with SWIG's ``-fastproxy`` binds methods as
``name = _swig_new_instance_method(_ifcopenshell_wrapper.cls_name)`` instead of
a ``def``, so the signature has to come from the compiled function, whose
docstring SWIG's autodoc feature fills with the C++ prototype(s).
"""
# The compiled module has no stub of its own, hence the dynamic import.
compiled = importlib.import_module("ifcopenshell._ifcopenshell_wrapper")
function = getattr(compiled, name, None)
if not isinstance(function, types.BuiltinFunctionType):
return None
return function.__doc__
def _split_prototype_args(text: str) -> list[str]:
items: list[str] = []
depth = 0
current = ""
for char in text:
if char in "(<[":
depth += 1
elif char in ")>]":
depth -= 1
if char == "," and depth == 0:
items.append(current.strip())
current = ""
else:
current += char
if current.strip():
items.append(current.strip())
return items
def signature_from_docstring(name: str, doc: Union[str, None], is_method: bool) -> Union[str, None]:
"""Rebuild the proxy ``def`` signature SWIG would have generated from an autodoc docstring.
One prototype whose defaults are all Python literals becomes named
parameters; several prototypes (overloads), or a default SWIG cannot
express as a literal such as an enum, become ``*args``, which is what SWIG
does. Checked against every ``def`` of a wrapper built without
``-fastproxy``: 773 of 773 signatures rebuild identically.
"""
# A method's compiled function is documented as `cls_name(...)`, sometimes as `name(...)`.
short = name.split("_", 1)[1] if is_method and "_" in name else name
prefixes = tuple(f"{prefix}(" for prefix in {name, short})
lines = [line.strip() for line in (doc or "").split("\n") if line.strip().startswith(prefixes)]
if not lines:
return None
fallback = "self, *args" if is_method else "*args"
if len(lines) > 1:
return f"def {name}({fallback}): ..."
match = re.match(r"[A-Za-z0-9_]+\((.*)\)(?: -> .*)?$", lines[0])
assert match
args: list[str] = []
for item in _split_prototype_args(match.group(1)):
default: Union[str, None] = None
if "=" in item:
item, default = item.rsplit("=", 1)
arg_name = item.split()[-1].lstrip("&*")
if default is None:
args.append(arg_name)
continue
try:
ast.literal_eval(default.strip())
except (ValueError, SyntaxError):
return f"def {name}({fallback}): ..."
args.append(f"{arg_name}={ast.unparse(ast.parse(default.strip(), mode='eval'))}")
return f"def {name}({', '.join(args)}): ..."
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.
@@ -119,7 +195,7 @@ def get_names_tree(tree: ast.Module) -> dict[str, set[SubnameType]]:
continue
subname_ = target.id
if subname_.startswith(("_", "thisown")):
if subname_.startswith(("_", "thisown")) and subname_ != "_is":
continue
value = subnode.value
@@ -131,6 +207,21 @@ def get_names_tree(tree: ast.Module) -> dict[str, set[SubnameType]]:
# - `matrix = property(matrix_getter, matrix_setter)`
# - `operation_str = staticmethod(operation_str)`
func = value.func
if isinstance(func, ast.Name) and func.id in (
"_swig_new_instance_method",
"_swig_new_static_method",
):
# -fastproxy: `name = _swig_new_instance_method(_ifcopenshell_wrapper.cls_name)`.
(bound,) = value.args
assert isinstance(bound, ast.Attribute) and isinstance(bound.value, ast.Name)
is_static = func.id == "_swig_new_static_method"
rebuilt = signature_from_docstring(
bound.attr, _wrapper_function_doc(bound.attr), not is_static
)
assert rebuilt is not None, bound.attr
rebuilt = rebuilt.replace(f"def {bound.attr}(", f"def {subname_}(", 1)
subnames.add(("@staticmethod", rebuilt) if is_static else rebuilt)
continue
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)]
@@ -203,6 +294,19 @@ def get_names_tree(tree: ast.Module) -> dict[str, set[SubnameType]]:
if not len(targets) == 1 or not isinstance(target := targets[0], ast.Name):
continue
node_name = target.id
if node_name.startswith("_"):
continue
value = node.value
if (
isinstance(value, ast.Attribute)
and isinstance(value.value, ast.Name)
and value.value.id == "_ifcopenshell_wrapper"
and not node_name.startswith("_")
):
# -fastproxy: a module function is `name = _ifcopenshell_wrapper.name`.
rebuilt = signature_from_docstring(value.attr, _wrapper_function_doc(value.attr), False)
if rebuilt is not None:
node_name = rebuilt.replace(f"def {value.attr}(", f"def {node_name}(", 1)
elif isinstance(node, ast.AnnAssign):
target = node.target
+1 -1
View File
@@ -110,7 +110,7 @@ ENDIF()
set(_python_libs_version "${Python_VERSION_MAJOR}${Python_VERSION_MINOR}")
INCLUDE_DIRECTORIES(BEFORE ${CMAKE_CURRENT_SOURCE_DIR})
SET(CMAKE_SWIG_FLAGS ${SWIG_DEFINES})
SET(CMAKE_SWIG_FLAGS ${SWIG_DEFINES} -fastproxy -fastdispatch)
set_source_files_properties(IfcPython.i PROPERTIES CPLUSPLUS ON)
# Rebuild on changes in other .i files.
+4 -1
View File
@@ -47,6 +47,10 @@
// TODO add '# pragma warning(pop)' to the very end of the file
%}
// Before the SWIG library includes, so their classes (iterators, std::vector and
// std::array wrappers) get prototype docstrings too; validate_stub reads them.
%feature("autodoc", "1");
%include "stdint.i"
%include "std_array.i"
%include "std_vector.i"
@@ -269,7 +273,6 @@
%}
// Create docstrings for generated python code.
%feature("autodoc", "1");
%include "utils/type_conversion.i"