Files
IfcOpenShell/src/wrappergen/clang_frontend.py
T
2026-03-31 20:51:46 +02:00

606 lines
23 KiB
Python

from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from .config import WrapperConfig
from .conventions import (
cpp_leaf_name,
enum_adapter_name,
handle_adapter_name,
is_enum_adapter,
normalize_cpp_type,
normalize_identifier,
pascal_case,
resolve_cpp_type_key,
safe_python_identifier,
sequence_adapter_name,
strip_pointer,
)
from .model import CallableModel, ClassModel, EnumModel, EnumValueModel, ModuleModel, ParameterModel
def _require_clang():
try:
from clang import cindex
except ImportError as exc:
raise RuntimeError(
"clang.cindex is required for wrapper generation. Install the Clang Python bindings "
"and ensure libclang is discoverable before rerunning the generator."
) from exc
return cindex
@dataclass(slots=True)
class _ParameterSpec:
has_default: bool
default_cpp_value: str | None
def _iter_children(cursor):
for child in cursor.get_children():
yield child
yield from _iter_children(child)
def _qualified_name(cursor) -> str:
cindex = _require_clang()
accepted = {
cindex.CursorKind.NAMESPACE,
cindex.CursorKind.CLASS_DECL,
cindex.CursorKind.STRUCT_DECL,
cindex.CursorKind.ENUM_DECL,
}
names: list[str] = []
current = cursor
while current is not None and current.kind != cindex.CursorKind.TRANSLATION_UNIT:
if current.kind in accepted and current.spelling:
names.append(current.spelling)
current = current.semantic_parent
return "::".join(reversed(names))
def _join_cpp_tokens(tokens: list[str]) -> str:
return "".join(tokens).strip()
def _parse_parameter_specs(cursor) -> list[_ParameterSpec]:
parameters = list(cursor.get_arguments())
if not parameters:
return []
tokens = [token.spelling for token in cursor.get_tokens()]
try:
start = tokens.index("(") + 1
except ValueError:
return [_ParameterSpec(False, None) for _ in parameters]
depth = 0
current: list[str] = []
groups: list[list[str]] = []
for token in tokens[start:]:
if token in {"(", "<", "["}:
depth += 1
elif token in {")", ">", "]"}:
if token == ")" and depth == 0:
if current:
groups.append(current[:])
break
depth = max(depth - 1, 0)
elif token == "," and depth == 0:
groups.append(current[:])
current = []
continue
current.append(token)
if current:
groups.append(current)
specs: list[_ParameterSpec] = []
for index, _ in enumerate(parameters):
group = groups[index] if index < len(groups) else []
if "=" not in group:
specs.append(_ParameterSpec(False, None))
continue
equals = group.index("=")
default_tokens = group[equals + 1 :]
specs.append(_ParameterSpec(True, _join_cpp_tokens(default_tokens) or None))
return specs
def _build_translation_unit(config: WrapperConfig, header: Path):
cindex = _require_clang()
index = cindex.Index.create()
arguments: list[str] = []
if config.compilation.compile_commands:
database = cindex.CompilationDatabase.fromDirectory(str(Path(config.compilation.compile_commands).resolve()))
candidates = [header.with_suffix(".cpp"), header.with_suffix(".cc"), header.with_suffix(".cxx")]
for candidate in candidates:
commands = database.getCompileCommands(str(candidate.resolve()))
if not commands:
continue
command = list(commands[0].arguments)
filtered: list[str] = []
skip_next = False
for argument in command[1:]:
if skip_next:
skip_next = False
continue
if argument in {"-c", "/c"}:
continue
if argument in {"-o", "/Fo", "/Fd"}:
skip_next = True
continue
if argument.endswith((".cpp", ".cc", ".cxx", ".c")):
continue
filtered.append(argument)
arguments.extend(filtered)
break
if not arguments:
arguments.extend(config.compilation.clang_args)
arguments.extend(f"-I{Path(include_dir).resolve()}" for include_dir in config.compilation.include_dirs)
arguments.extend(f"-D{define}" for define in config.compilation.defines)
return index.parse(str(header.resolve()), args=arguments)
def _cursor_file_path(cursor) -> Path | None:
file = cursor.location.file
if file is None:
return None
try:
return Path(file.name).resolve()
except OSError:
return None
def _is_in_allowed_headers(cursor, allowed_headers: set[Path]) -> bool:
path = _cursor_file_path(cursor)
return path in allowed_headers if path is not None else False
def _is_in_allowed_namespace(cpp_name: str, config: WrapperConfig) -> bool:
if not config.allowed_namespaces:
return True
return any(cpp_name == namespace or cpp_name.startswith(f"{namespace}::") for namespace in config.allowed_namespaces)
def _matches_ignore(cpp_name: str, ignored: list[str]) -> bool:
return cpp_name in ignored
def _matches_ignored_namespace(cpp_name: str, ignored_namespaces: list[str]) -> bool:
return any(cpp_name == namespace or cpp_name.startswith(f"{namespace}::") for namespace in ignored_namespaces)
def _has_export_macro(cursor, macro_name: str) -> bool:
return any(token.spelling == macro_name for token in cursor.get_tokens())
def _is_top_level_type(cursor) -> bool:
cindex = _require_clang()
parent = cursor.semantic_parent
return parent is not None and parent.kind in {cindex.CursorKind.TRANSLATION_UNIT, cindex.CursorKind.NAMESPACE}
def _collect_enum_cursors(translation_units, config: WrapperConfig, allowed_headers: set[Path]) -> dict[str, object]:
cindex = _require_clang()
enums: dict[str, object] = {}
for translation_unit in translation_units:
for cursor in _iter_children(translation_unit.cursor):
if cursor.kind != cindex.CursorKind.ENUM_DECL:
continue
if not cursor.is_definition():
continue
if not _is_top_level_type(cursor):
continue
if not _is_in_allowed_headers(cursor, allowed_headers):
continue
cpp_name = _qualified_name(cursor)
if not cpp_name or not _is_in_allowed_namespace(cpp_name, config):
continue
if _matches_ignored_namespace(cpp_name, config.ignore.namespaces):
continue
if _matches_ignore(cpp_name, config.ignore.enums):
continue
enums[normalize_cpp_type(cpp_name)] = cursor
return enums
def _collect_class_cursors(translation_units, config: WrapperConfig, allowed_headers: set[Path]) -> dict[str, object]:
cindex = _require_clang()
classes: dict[str, object] = {}
for translation_unit in translation_units:
for cursor in _iter_children(translation_unit.cursor):
if cursor.kind not in {cindex.CursorKind.CLASS_DECL, cindex.CursorKind.STRUCT_DECL}:
continue
if not cursor.is_definition():
continue
if not _is_top_level_type(cursor):
continue
if not _is_in_allowed_headers(cursor, allowed_headers):
continue
cpp_name = _qualified_name(cursor)
if not cpp_name or not _is_in_allowed_namespace(cpp_name, config):
continue
if _matches_ignored_namespace(cpp_name, config.ignore.namespaces):
continue
if _matches_ignore(cpp_name, config.ignore.classes):
continue
if config.require_exported_classes and not _has_export_macro(cursor, config.export_macro):
continue
classes[normalize_cpp_type(cpp_name)] = cursor
return classes
def _normalized_type_adapters(config: WrapperConfig) -> dict[str, str]:
return {normalize_cpp_type(cpp_type): adapter for cpp_type, adapter in config.type_adapters.items()}
def _python_class_name(cpp_name: str, config: WrapperConfig) -> str:
return config.class_names.get(cpp_name, cpp_leaf_name(cpp_name))
def _python_enum_name(cpp_name: str, config: WrapperConfig) -> str:
return config.enum_names.get(cpp_name, pascal_case(cpp_leaf_name(cpp_name)))
def _parameter_python_name(raw_name: str, index: int, config: WrapperConfig) -> str:
source = raw_name or f"arg{index}"
return safe_python_identifier(normalize_identifier(config.parameter_names.get(source, source)))
def _owner_cpp_name(cpp_name: str, config: WrapperConfig) -> str | None:
return config.class_owner_types.get(cpp_name)
def _handle_kind(cpp_name: str, config: WrapperConfig) -> str:
return config.class_handle_kinds.get(cpp_name, config.default_class_handle_kind)
def _resolve_parameter_adapter(
cpp_type: str,
scalar_adapters: dict[str, str],
enum_cursors: dict[str, object],
) -> str | None:
canonical = normalize_cpp_type(cpp_type)
if canonical in scalar_adapters:
return scalar_adapters[canonical]
enum_key = resolve_cpp_type_key(cpp_type, set(enum_cursors))
if enum_key is not None:
return enum_adapter_name(enum_key)
return None
def _vector_inner_type(cpp_type: str) -> str | None:
canonical = normalize_cpp_type(cpp_type)
prefix = "std::vector<"
if not canonical.startswith(prefix) or not canonical.endswith(">"):
return None
return canonical[len(prefix) : -1]
def _resolve_return_adapter(
cpp_type: str,
scalar_adapters: dict[str, str],
enum_cursors: dict[str, object],
class_models_by_cpp: dict[str, ClassModel],
) -> str | None:
canonical = normalize_cpp_type(cpp_type)
if canonical in scalar_adapters:
return scalar_adapters[canonical]
enum_key = resolve_cpp_type_key(cpp_type, set(enum_cursors))
if enum_key is not None:
return enum_adapter_name(enum_key)
vector_inner = _vector_inner_type(cpp_type)
if vector_inner:
sequence_key = resolve_cpp_type_key(vector_inner, set(class_models_by_cpp))
if sequence_key is not None:
return sequence_adapter_name(sequence_key)
pointee = resolve_cpp_type_key(strip_pointer(cpp_type), set(class_models_by_cpp))
if pointee is not None:
target = class_models_by_cpp[pointee]
if canonical.endswith("*") and target.handle_kind == "shared_ptr":
return None
return handle_adapter_name(pointee)
return None
def _python_default_value(
cpp_value: str | None,
adapter: str,
enum_py_names: dict[str, str],
) -> str | None:
if cpp_value is None:
return None
if adapter == "bool":
if cpp_value == "true":
return "True"
if cpp_value == "false":
return "False"
return None
if adapter == "integer":
return cpp_value if cpp_value.lstrip("-").isdigit() else None
if adapter == "string":
return cpp_value if cpp_value.startswith(("\"", "'")) else None
if is_enum_adapter(adapter):
enum_name = enum_py_names.get(adapter.split(":", 1)[1])
if enum_name is None:
return None
return f"{enum_name}.{cpp_value.rsplit('::', 1)[-1]}"
return None
def _build_parameter_models(
cursor,
config: WrapperConfig,
scalar_adapters: dict[str, str],
enum_cursors: dict[str, object],
) -> list[ParameterModel] | None:
parameter_specs = _parse_parameter_specs(cursor)
parameter_models: list[ParameterModel] = []
for index, parameter in enumerate(cursor.get_arguments()):
adapter = _resolve_parameter_adapter(parameter.type.spelling, scalar_adapters, enum_cursors)
if adapter is None:
return None
spec = parameter_specs[index] if index < len(parameter_specs) else _ParameterSpec(False, None)
parameter_models.append(
ParameterModel(
name=_parameter_python_name(parameter.spelling, index, config),
cpp_name=parameter.spelling or f"arg{index}",
cpp_type=parameter.type.spelling,
adapter=adapter,
has_default=spec.has_default,
default_cpp_value=spec.default_cpp_value,
)
)
return parameter_models
def _is_deleted(cursor) -> bool:
tokens = [token.spelling for token in cursor.get_tokens()]
return "=" in tokens and "delete" in tokens
def _is_copy_or_move_constructor(cursor, owner_cpp_name: str) -> bool:
parameters = list(cursor.get_arguments())
if len(parameters) != 1:
return False
return strip_pointer(parameters[0].type.spelling) == normalize_cpp_type(owner_cpp_name)
def _constructor_py_name(parameters: list[ParameterModel], minimum_arity: int) -> str:
if minimum_arity == 0:
return "create"
required = parameters[:minimum_arity]
suffix = "_".join(parameter.name for parameter in required)
return safe_python_identifier(f"with_{suffix}")
def _finalize_overload_names(callables: list[CallableModel]) -> None:
groups: dict[str, list[CallableModel]] = defaultdict(list)
for callable_model in callables:
groups[callable_model.py_name].append(callable_model)
for group in groups.values():
if len(group) == 1:
continue
for index, callable_model in enumerate(group, start=1):
parameter_suffix = "_".join(parameter.name for parameter in callable_model.parameters)
if callable_model.kind == "constructor":
callable_model.py_name = f"{callable_model.py_name}_{parameter_suffix}" if parameter_suffix else f"{callable_model.py_name}_overload_{index}"
else:
callable_model.py_name = f"{callable_model.py_name}_with_{parameter_suffix}" if parameter_suffix else f"{callable_model.py_name}_overload_{index}"
callable_model.c_name = normalize_identifier(callable_model.py_name)
def _deduplicate_callables(callables: list[CallableModel]) -> list[CallableModel]:
unique: list[CallableModel] = []
seen: set[tuple[str, str, str, tuple[str, ...]]] = set()
for callable_model in callables:
key = (
callable_model.kind,
callable_model.cpp_name,
callable_model.return_adapter,
tuple(normalize_cpp_type(parameter.cpp_type) for parameter in callable_model.parameters),
)
if key in seen:
continue
seen.add(key)
unique.append(callable_model)
return unique
def _discover_constructors(
class_cursor,
owner: ClassModel,
config: WrapperConfig,
scalar_adapters: dict[str, str],
enum_cursors: dict[str, object],
) -> list[CallableModel]:
cindex = _require_clang()
constructors: list[CallableModel] = []
for child in class_cursor.get_children():
if child.kind != cindex.CursorKind.CONSTRUCTOR:
continue
if child.access_specifier != cindex.AccessSpecifier.PUBLIC:
continue
if _is_deleted(child) or _is_copy_or_move_constructor(child, owner.cpp_name):
continue
parameters = _build_parameter_models(child, config, scalar_adapters, enum_cursors)
if parameters is None:
continue
callable_model = CallableModel(
kind="constructor",
owner_cpp_name=owner.cpp_name,
owner_py_name=owner.py_name,
cpp_name=cpp_leaf_name(owner.cpp_name),
py_name="create",
c_name="new",
return_cpp_type=owner.cpp_name,
return_adapter=handle_adapter_name(owner.cpp_name),
parameters=parameters,
)
callable_model.py_name = _constructor_py_name(parameters, callable_model.minimum_arity)
constructors.append(callable_model)
return constructors
def _discover_methods(
class_cursor,
owner: ClassModel,
config: WrapperConfig,
scalar_adapters: dict[str, str],
enum_cursors: dict[str, object],
class_models_by_cpp: dict[str, ClassModel],
) -> list[CallableModel]:
cindex = _require_clang()
methods: list[CallableModel] = []
for child in class_cursor.get_children():
if child.kind != cindex.CursorKind.CXX_METHOD:
continue
if child.access_specifier != cindex.AccessSpecifier.PUBLIC:
continue
if child.spelling.startswith("operator") or _is_deleted(child):
continue
if child.is_static_method():
continue
qualified_name = f"{owner.cpp_name}::{child.spelling}"
if _matches_ignore(qualified_name, config.ignore.methods):
continue
return_adapter = _resolve_return_adapter(child.result_type.spelling, scalar_adapters, enum_cursors, class_models_by_cpp)
if return_adapter is None:
continue
parameters = _build_parameter_models(child, config, scalar_adapters, enum_cursors)
if parameters is None:
continue
methods.append(
CallableModel(
kind="method",
owner_cpp_name=owner.cpp_name,
owner_py_name=owner.py_name,
cpp_name=child.spelling,
py_name=safe_python_identifier(normalize_identifier(child.spelling)),
c_name=normalize_identifier(child.spelling),
return_cpp_type=child.result_type.spelling,
return_adapter=return_adapter,
parameters=parameters,
)
)
return methods
def _build_enum_model(cpp_name: str, cursor, config: WrapperConfig) -> EnumModel:
py_name = _python_enum_name(cpp_name, config)
c_name = default_c_name = f"{config.c_prefix}_{normalize_identifier(py_name)}_t"
values: list[EnumValueModel] = []
for child in cursor.get_children():
if child.spelling:
values.append(
EnumValueModel(
name=child.spelling,
c_name=f"{default_c_name.upper()}_{child.spelling}",
value=child.enum_value,
)
)
return EnumModel(cpp_name=cpp_name, py_name=py_name, c_name=c_name, values=values)
def build_module_model(config: WrapperConfig) -> ModuleModel:
translation_units = [_build_translation_unit(config, Path(header)) for header in config.compilation.headers]
allowed_headers = {Path(header).resolve() for header in config.compilation.headers}
enum_cursors = _collect_enum_cursors(translation_units, config, allowed_headers)
class_cursors = _collect_class_cursors(translation_units, config, allowed_headers)
scalar_adapters = _normalized_type_adapters(config)
class_models_by_cpp: dict[str, ClassModel] = {}
for normalized_cpp_name, cursor in class_cursors.items():
cpp_name = _qualified_name(cursor)
owner_cpp_name = _owner_cpp_name(cpp_name, config)
owner_py_name = _python_class_name(owner_cpp_name, config) if owner_cpp_name else None
class_models_by_cpp[normalized_cpp_name] = ClassModel(
cpp_name=cpp_name,
py_name=_python_class_name(cpp_name, config),
handle_kind=_handle_kind(cpp_name, config),
owner_cpp_name=owner_cpp_name,
owner_py_name=owner_py_name,
)
for normalized_cpp_name, cursor in class_cursors.items():
owner = class_models_by_cpp[normalized_cpp_name]
owner.callables.extend(_discover_constructors(cursor, owner, config, scalar_adapters, enum_cursors))
owner.callables.extend(_discover_methods(cursor, owner, config, scalar_adapters, enum_cursors, class_models_by_cpp))
owner.callables = _deduplicate_callables(owner.callables)
_finalize_overload_names(owner.callables)
used_class_names: set[str] = set()
used_enum_names: set[str] = set()
for class_model in class_models_by_cpp.values():
if class_model.callables:
used_class_names.add(normalize_cpp_type(class_model.cpp_name))
if class_model.owner_cpp_name:
used_class_names.add(normalize_cpp_type(class_model.owner_cpp_name))
for callable_model in class_model.callables:
if is_enum_adapter(callable_model.return_adapter):
used_enum_names.add(callable_model.return_adapter.split(":", 1)[1])
if callable_model.return_adapter.startswith("handle:"):
used_class_names.add(callable_model.return_adapter.split(":", 1)[1])
if callable_model.return_adapter.startswith("sequence:"):
used_class_names.add(callable_model.return_adapter.split(":", 1)[1])
for parameter in callable_model.parameters:
if is_enum_adapter(parameter.adapter):
used_enum_names.add(parameter.adapter.split(":", 1)[1])
selected_classes: list[ClassModel] = []
for normalized_cpp_name, class_model in class_models_by_cpp.items():
if normalized_cpp_name in used_class_names:
selected_classes.append(class_model)
enum_models_by_cpp: dict[str, EnumModel] = {}
for normalized_cpp_name in sorted(used_enum_names):
cursor = enum_cursors.get(normalized_cpp_name)
if cursor is None:
continue
cpp_name = _qualified_name(cursor)
enum_models_by_cpp[normalized_cpp_name] = _build_enum_model(cpp_name, cursor, config)
enum_py_names = {normalized_cpp_name: model.py_name for normalized_cpp_name, model in enum_models_by_cpp.items()}
for class_model in selected_classes:
for callable_model in class_model.callables:
for parameter in callable_model.parameters:
if parameter.has_default:
parameter.default_python_value = _python_default_value(
parameter.default_cpp_value,
parameter.adapter,
enum_py_names,
)
if not any(class_model.callables for class_model in selected_classes):
raise RuntimeError("No supported public classes or methods were discovered in the configured headers")
source_headers: list[str] = []
seen_headers: set[str] = set()
for class_model in selected_classes:
cursor = class_cursors.get(normalize_cpp_type(class_model.cpp_name))
header = _cursor_file_path(cursor) if cursor is not None else None
if header is not None and header.name not in seen_headers:
seen_headers.add(header.name)
source_headers.append(header.name)
for normalized_cpp_name in enum_models_by_cpp:
cursor = enum_cursors.get(normalized_cpp_name)
header = _cursor_file_path(cursor) if cursor is not None else None
if header is not None and header.name not in seen_headers:
seen_headers.add(header.name)
source_headers.append(header.name)
return ModuleModel(
module_name=config.module_name,
c_prefix=config.c_prefix,
api_header_name=config.api_header_name,
api_implementation_name=config.api_implementation_name,
extension_source_name=config.extension_source_name,
python_source_name=config.python_source_name,
source_headers=source_headers,
classes=selected_classes,
enums=list(enum_models_by_cpp.values()),
)