From 08e65b66e71827f97be36c71d3b76893faf3a05f Mon Sep 17 00:00:00 2001 From: Shane Loretz Date: Tue, 17 Mar 2026 00:05:00 +0000 Subject: [PATCH] Try using sip-build and Python3_add_library for Qt5/Qt6 Signed-off-by: Shane Loretz --- CMakeLists.txt | 1 - cmake/sip_configure.py | 231 ----------------------------------------- cmake/sip_helper.cmake | 119 ++++++++++----------- package.xml | 1 + 4 files changed, 55 insertions(+), 297 deletions(-) delete mode 100644 cmake/sip_configure.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c00505..97f5cfe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,6 @@ ament_python_install_package(${PROJECT_NAME} install(FILES cmake/shiboken_helper.cmake - cmake/sip_configure.py cmake/sip_helper.cmake DESTINATION share/${PROJECT_NAME}/cmake) diff --git a/cmake/sip_configure.py b/cmake/sip_configure.py deleted file mode 100644 index 5210ee5..0000000 --- a/cmake/sip_configure.py +++ /dev/null @@ -1,231 +0,0 @@ -import copy -import os -import re -import shutil -import subprocess -import sys -import tempfile - -import PyQt5 -from PyQt5 import QtCore -import sipconfig - -libqt5_rename = False - - -class Configuration(sipconfig.Configuration): - - def __init__(self): - env = copy.copy(os.environ) - env['QT_SELECT'] = '5' - qmake_exe = 'qmake-qt5' if shutil.which('qmake-qt5') else 'qmake' - qtconfig = subprocess.check_output( - [qmake_exe, '-query'], env=env, universal_newlines=True) - qtconfig = dict(line.split(':', 1) for line in qtconfig.splitlines()) - pyqtconfig = { - 'qt_archdata_dir': qtconfig['QT_INSTALL_DATA'], - 'qt_data_dir': qtconfig['QT_INSTALL_DATA'], - 'qt_dir': qtconfig['QT_INSTALL_PREFIX'], - 'qt_inc_dir': qtconfig['QT_INSTALL_HEADERS'], - 'qt_lib_dir': qtconfig['QT_INSTALL_LIBS'], - 'qt_threaded': 1, - 'qt_version': QtCore.QT_VERSION, - 'qt_winconfig': 'shared exceptions', - } - if sys.platform == 'darwin': - if os.path.exists(os.path.join(qtconfig['QT_INSTALL_LIBS'], 'QtCore.framework')): - pyqtconfig['qt_framework'] = 1 - else: - global libqt5_rename - libqt5_rename = True - - sipconfig.Configuration.__init__(self, [pyqtconfig]) - - macros = sipconfig._default_macros.copy() - macros['INCDIR_QT'] = qtconfig['QT_INSTALL_HEADERS'] - macros['LIBDIR_QT'] = qtconfig['QT_INSTALL_LIBS'] - macros['MOC'] = 'moc-qt5' if shutil.which('moc-qt5') else 'moc' - self.set_build_macros(macros) - - -def get_sip_dir_flags(config): - """ - Get the extra SIP flags needed by the imported qt module, and locate PyQt5 sip install files. - - Note that this normally only includes those flags (-x and -t) that relate to SIP's versioning - system. - """ - try: - sip_dir = config.pyqt_sip_dir - sip_flags = config.pyqt_sip_flags - return sip_dir, sip_flags - except AttributeError: - pass - - # We didn't find the sip_dir and sip_flags from the config, continue looking - - # sipconfig.Configuration does not have a pyqt_sip_dir or pyqt_sip_flags AttributeError - sip_flags = QtCore.PYQT_CONFIGURATION['sip_flags'] - - candidate_sip_dirs = [] - - # Archlinux installs sip files here by default - candidate_sip_dirs.append(os.path.join(PyQt5.__path__[0], 'bindings')) - - # sip4 installs here by default - candidate_sip_dirs.append(os.path.join(sipconfig._pkg_config['default_sip_dir'], 'PyQt5')) - - # Homebrew installs sip files here by default - candidate_sip_dirs.append(os.path.join(sipconfig._pkg_config['default_sip_dir'], 'Qt5')) - - for sip_dir in candidate_sip_dirs: - if os.path.exists(sip_dir): - return sip_dir, sip_flags - - raise FileNotFoundError('The sip directory for PyQt5 could not be located. Please ensure' + - ' that PyQt5 is installed') - - -if len(sys.argv) != 8: - print('usage: %s build-dir sip-file output_dir include_dirs libs lib_dirs ldflags' % - sys.argv[0]) - sys.exit(1) - -# The SIP build folder, the SIP file, the output directory, the include -# directories, the libraries, the library directories and the linker -# flags. -build_dir, sip_file, output_dir, include_dirs, libs, lib_dirs, ldflags = sys.argv[1:] - -# The name of the SIP build file generated by SIP and used by the build system. -build_file = 'pyqtscripting.sbf' - -# Get the PyQt configuration information. -config = Configuration() - -sip_dir, sip_flags = get_sip_dir_flags(config) - -try: - os.makedirs(build_dir) -except OSError: - pass - -# Run SIP to generate the code. Note that we tell SIP where to find the qt -# module's specification files using the -I flag. - -sip_bin = config.sip_bin -# Without the .exe, this might actually be a directory in Windows -if sys.platform == 'win32' and os.path.isdir(sip_bin): - sip_bin += '.exe' - -# SIP4 has an incompatibility with Qt 5.15.6. In particular, Qt 5.15.6 uses a new SIP directive -# called py_ssize_t_clean in QtCoremod.sip that SIP4 does not understand. -# -# Unfortunately, the combination of SIP4 and Qt 5.15.6 is common. Archlinux, Ubuntu 22.04 -# and RHEL-9 all have this combination. On Ubuntu 22.04, there is a custom patch to SIP4 -# to make it understand the py_ssize_t_clean tag, so the combination works. But on most -# other platforms, it fails. -# -# To workaround this, copy all of the SIP files into a temporary directory, remove the offending -# line, and then use that temporary directory as the include path. This is unnecessary on -# Ubuntu 22.04, but shouldn't hurt anything there. -with tempfile.TemporaryDirectory() as tmpdirname: - shutil.copytree(sip_dir, tmpdirname, dirs_exist_ok=True) - - output = '' - with open(os.path.join(tmpdirname, 'QtCore', 'QtCoremod.sip'), 'r') as infp: - for line in infp: - if line.startswith('%Module(name='): - result = re.sub(r', py_ssize_t_clean=True', '', line) - output += result - else: - output += line - - with open(os.path.join(tmpdirname, 'QtCore', 'QtCoremod.sip'), 'w') as outfp: - outfp.write(output) - - cmd = [ - sip_bin, - '-c', build_dir, - '-b', os.path.join(build_dir, build_file), - '-I', tmpdirname, - '-w' - ] - cmd += sip_flags.split(' ') - cmd.append(sip_file) - - subprocess.check_call(cmd) - -# Create the Makefile. The QtModuleMakefile class provided by the -# pyqtconfig module takes care of all the extra preprocessor, compiler and -# linker flags needed by the Qt library. -makefile = sipconfig.SIPModuleMakefile( - dir=build_dir, - configuration=config, - build_file=build_file, - qt=['QtCore', 'QtGui'] -) - -# hack to override makefile behavior which always prepend -l to libraries -# which is wrong for absolute paths -default_platform_lib_function = sipconfig.SIPModuleMakefile.platform_lib - - -def custom_platform_lib_function(self, clib, framework=0): - if not clib or clib.isspace(): - return None - # Only add '-l' if a library doesn't already start with '-l' and is not an absolute path - if os.path.isabs(clib) or clib.startswith('-l'): - return clib - - global libqt5_rename - # sip renames libs to Qt5 automatically on Linux, but not on macOS - if libqt5_rename and not framework and clib.startswith('Qt') and not clib.startswith('Qt5'): - return '-lQt5' + clib[2:] - - return default_platform_lib_function(self, clib, framework) - - -sipconfig.SIPModuleMakefile.platform_lib = custom_platform_lib_function - - -# split paths on whitespace -# while dealing with whitespaces within the paths if they are escaped with backslashes -def split_paths(paths): - paths = re.split('(?<=[^\\\\]) ', paths) - return paths - - -for include_dir in split_paths(include_dirs): - include_dir = include_dir.replace('\\', '') - makefile.extra_include_dirs.append(include_dir) -for lib in split_paths(libs): - makefile.extra_libs.append(lib) -for lib_dir in split_paths(lib_dirs): - lib_dir = lib_dir.replace('\\', '') - makefile.extra_lib_dirs.append(lib_dir) -for ldflag in ldflags.split('\\ '): - makefile.LFLAGS.append(ldflag) - -# redirect location of generated library -makefile._target = '"%s"' % os.path.join(output_dir, makefile._target) - -# Force c++17 -if sys.platform == 'win32': - makefile.extra_cxxflags.append('/std:c++17') - # The __cplusplus flag is not properly set on Windows for backwards - # compatibilty. This flag sets it correctly - makefile.CXXFLAGS.append('/Zc:__cplusplus') -else: - makefile.extra_cxxflags.append('-std=c++17') - -# Finalise the Makefile, preparing it to be saved to disk -makefile.finalise() - -# Replace Qt variables from libraries -libs = makefile.LIBS.as_list() -for i in range(len(libs)): - libs[i] = libs[i].replace('$$[QT_INSTALL_LIBS]', config.build_macros()['LIBDIR_QT']) -makefile.LIBS.set(libs) - -# Generate the Makefile itself -makefile.generate() diff --git a/cmake/sip_helper.cmake b/cmake/sip_helper.cmake index a5ac3c2..8f4fd24 100644 --- a/cmake/sip_helper.cmake +++ b/cmake/sip_helper.cmake @@ -5,47 +5,30 @@ set(__PYTHON_QT_BINDING_SIP_HELPER_INCLUDED TRUE) set(__PYTHON_QT_BINDING_SIP_HELPER_DIR ${CMAKE_CURRENT_LIST_DIR}) -# By default, without the settings below, find_package(Python3) will attempt -# to find the newest python version it can, and additionally will find the -# most specific version. For instance, on a system that has -# /usr/bin/python3.10, /usr/bin/python3.11, and /usr/bin/python3, it will find -# /usr/bin/python3.11, even if /usr/bin/python3 points to /usr/bin/python3.10. -# The behavior we want is to prefer the "system" installed version unless the -# user specifically tells us othewise through the Python3_EXECUTABLE hint. -# Setting CMP0094 to NEW means that the search will stop after the first -# python version is found. Setting Python3_FIND_UNVERSIONED_NAMES means that -# the search will prefer /usr/bin/python3 over /usr/bin/python3.11. And that -# latter functionality is only available in CMake 3.20 or later, so we need -# at least that version. cmake_minimum_required(VERSION 3.20) cmake_policy(SET CMP0094 NEW) set(Python3_FIND_UNVERSIONED_NAMES FIRST) find_package(Python3 ${Python3_VERSION} REQUIRED COMPONENTS Interpreter Development) +# Check if modern sipbuild is available via python module execute_process( - COMMAND ${Python3_EXECUTABLE} -c "import sipconfig; print(sipconfig.Configuration().sip_bin)" - OUTPUT_VARIABLE PYTHON_SIP_EXECUTABLE + COMMAND ${Python3_EXECUTABLE} -c "import sipbuild" + RESULT_VARIABLE _sipbuild_res ERROR_QUIET) -if(PYTHON_SIP_EXECUTABLE) - string(STRIP ${PYTHON_SIP_EXECUTABLE} SIP_EXECUTABLE) -else() - find_program(SIP_EXECUTABLE sip) -endif() - -if(SIP_EXECUTABLE) - message(STATUS "SIP binding generator available at: ${SIP_EXECUTABLE}") +if(_sipbuild_res EQUAL 0) + message(STATUS "Modern SIP binding generator (sip-build) is available.") set(sip_helper_FOUND TRUE) else() - message(STATUS "SIP binding generator NOT available.") + message(STATUS "Modern SIP binding generator NOT available.") set(sip_helper_NOTFOUND TRUE) endif() # # Run the SIP generator and compile the generated code into a library. # -# .. note:: The target lib${PROJECT_NAME} is created. +# .. note:: Creates a target named lib${PROJECT_NAME} # # :param PROJECT_NAME: The name of the sip project # :type PROJECT_NAME: string @@ -53,17 +36,14 @@ endif() # :type SIP_FILE: string # # The following options can be used to override the default behavior: -# SIP_CONFIGURE: the used configure script for SIP -# (default: sip_configure.py in the same folder as this file) +# SIP_CONFIGURE: (IGNORED) Retained for CMake API compatibility only. # SOURCE_DIR: the source dir (default: ${PROJECT_SOURCE_DIR}/src) # LIBRARY_DIR: the library dir (default: ${PROJECT_SOURCE_DIR}/src) # BINARY_DIR: the binary dir (default: ${PROJECT_BINARY_DIR}) # # The following keywords arguments can be used to specify: -# DEPENDS: depends for the custom command -# (should list all sip and header files) +# DEPENDS: depends for the custom command (should list all sip and header files) # DEPENDENCIES: target dependencies -# (should list the library for which SIP generates the bindings) # function(build_sip_binding PROJECT_NAME SIP_FILE) cmake_parse_arguments(sip "" "SIP_CONFIGURE;SOURCE_DIR;LIBRARY_DIR;BINARY_DIR" "DEPENDS;DEPENDENCIES" ${ARGN}) @@ -71,11 +51,11 @@ function(build_sip_binding PROJECT_NAME SIP_FILE) message(WARNING "build_sip_binding(${PROJECT_NAME}) called with unused arguments: ${sip_UNPARSED_ARGUMENTS}") endif() - # set default values for optional arguments - if(NOT sip_SIP_CONFIGURE) - # default to sip_configure.py in this directory - set(sip_SIP_CONFIGURE ${__PYTHON_QT_BINDING_SIP_HELPER_DIR}/sip_configure.py) + if(sip_SIP_CONFIGURE) + message(WARNING "SIP_CONFIGURE argument is deprecated and ignored. CMake now handles configuration natively.") endif() + + # set default values for optional arguments if(NOT sip_SOURCE_DIR) set(sip_SOURCE_DIR ${PROJECT_SOURCE_DIR}/src) endif() @@ -88,43 +68,52 @@ function(build_sip_binding PROJECT_NAME SIP_FILE) set(SIP_BUILD_DIR ${sip_BINARY_DIR}/sip/${PROJECT_NAME}) - set(INCLUDE_DIRS ${${PROJECT_NAME}_INCLUDE_DIRS} ${Python3_INCLUDE_DIRS}) - set(LIBRARIES ${${PROJECT_NAME}_LIBRARIES}) - set(LIBRARY_DIRS ${${PROJECT_NAME}_LIBRARY_DIRS}) - set(LDFLAGS_OTHER ${${PROJECT_NAME}_LDFLAGS_OTHER}) + # Extract the filename from the SIP_FILE path + get_filename_component(SIP_FILE_NAME ${SIP_FILE} NAME) - add_custom_command( - OUTPUT ${SIP_BUILD_DIR}/Makefile - COMMAND ${Python3_EXECUTABLE} ${sip_SIP_CONFIGURE} ${SIP_BUILD_DIR} ${SIP_FILE} ${sip_LIBRARY_DIR} - \"${INCLUDE_DIRS}\" \"${LIBRARIES}\" \"${LIBRARY_DIRS}\" \"${LDFLAGS_OTHER}\" - DEPENDS ${sip_SIP_CONFIGURE} ${SIP_FILE} ${sip_DEPENDS} - WORKING_DIRECTORY ${sip_SOURCE_DIR} - COMMENT "Running SIP generator for ${PROJECT_NAME} Python bindings..." - ) + # Generate a pyproject.toml to be given to sip-build + file(MAKE_DIRECTORY ${SIP_BUILD_DIR}) + set(TOML_CONTENT +"[build-system] +requires = [\"sip >= 5.3\"] +build-backend = \"sipbuild.api\" - if(NOT EXISTS "${sip_LIBRARY_DIR}") - file(MAKE_DIRECTORY ${sip_LIBRARY_DIR}) - endif() +[project] +name = \"${PROJECT_NAME}\" +version = \"1.0.0\" - if(WIN32) - set(MAKE_EXECUTABLE NMake.exe) - else() - find_program(MAKE_PROGRAM NAMES make) - message(STATUS "Found required make: ${MAKE_PROGRAM}") - set(MAKE_EXECUTABLE ${MAKE_PROGRAM}) - endif() +[tool.sip.metadata] +name = \"${PROJECT_NAME}\" + +[tool.sip.project] +sip-files-dir = \"${sip_SOURCE_DIR}\" + +[tool.sip.bindings.${PROJECT_NAME}] +sip-file = \"${SIP_FILE_NAME}\" +") + file(WRITE ${SIP_BUILD_DIR}/pyproject.toml "${TOML_CONTENT}") + + # Expect sip-build to produce a single output file because of the --concatenate 1 agrument below + set(GENERATED_CPP ${SIP_BUILD_DIR}/build/${PROJECT_NAME}/sip${PROJECT_NAME}part0.cpp) + # Generate code for a cPython extension using sip-build add_custom_command( - OUTPUT ${sip_LIBRARY_DIR}/lib${PROJECT_NAME}${CMAKE_SHARED_LIBRARY_SUFFIX} - COMMAND ${MAKE_EXECUTABLE} - DEPENDS ${SIP_BUILD_DIR}/Makefile + OUTPUT ${GENERATED_CPP} + COMMAND ${Python3_EXECUTABLE} -m sipbuild.tools.build --no-compile --concatenate 1 --build-dir build + DEPENDS ${SIP_FILE} ${sip_DEPENDS} WORKING_DIRECTORY ${SIP_BUILD_DIR} - COMMENT "Compiling generated code for ${PROJECT_NAME} Python bindings..." + COMMENT "Generating C++ code for ${PROJECT_NAME} Python bindings using sip-build..." ) - add_custom_target(lib${PROJECT_NAME} ALL - DEPENDS ${sip_LIBRARY_DIR}/lib${PROJECT_NAME}${CMAKE_SHARED_LIBRARY_SUFFIX} - COMMENT "Meta target for ${PROJECT_NAME} Python bindings..." - ) - add_dependencies(lib${PROJECT_NAME} ${sip_DEPENDENCIES}) -endfunction() + # Build the cPython extension natively using CMake + Python3_add_library(lib${PROJECT_NAME} MODULE ${GENERATED_CPP}) + + # Link project dependencies against this target + target_include_directories(lib${PROJECT_NAME} PRIVATE ${${PROJECT_NAME}_INCLUDE_DIRS}) + target_link_libraries(lib${PROJECT_NAME} PRIVATE ${${PROJECT_NAME}_LIBRARIES}) + target_link_directories(lib${PROJECT_NAME} PRIVATE ${${PROJECT_NAME}_LIBRARY_DIRS}) + + if(${PROJECT_NAME}_LDFLAGS_OTHER) + target_link_options(lib${PROJECT_NAME} PRIVATE ${${PROJECT_NAME}_LDFLAGS_OTHER}) + endif() +endfunction() \ No newline at end of file diff --git a/package.xml b/package.xml index df2b169..29dc41c 100644 --- a/package.xml +++ b/package.xml @@ -32,6 +32,7 @@ python3-qt5-bindings python3-qt5-bindings + ament_cmake_pytest ament_lint_auto