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()), )