Files
ceph/cmake/modules/Distutils.cmake
Kefu Chai 91de0e1933 cmake: migrate Python module installation from setup.py to pip
Replace 'setup.py install' with 'pip install --use-pep517' to fix
Cython compilation failures and eliminate deprecation warnings.

Problem Statement:
The build process for Cython modules involves preprocessing .pyx files
(e.g., generating rbd_processed.pyx from rbd.pyx) and then cythonizing
with specific compiler_directives. The previous approach using separate
'setup.py build' and 'setup.py install' commands caused this failure:

```
Error compiling Cython file:
------------------------------------------------------------
...
        """
        name = cstr(name, 'name')
        cdef:
            rados_ioctx_t _ioctx = convert_ioctx(ioctx)
            char *_name = name
            librbd_progress_fn_t _prog_cb = &no_op_progress_callback
                                            ^
------------------------------------------------------------

rbd_processed.pyx:781:44: Cannot assign type 'int (*)(uint64_t, uint64_t, void *) except? -1' to 'librbd_progress_fn_t'. Exception values are incompatible. Suggest adding 'noexcept' to type 'int (uint64_t, uint64_t, void *) except? -1'.
```

This occurs because:
1. 'setup.py build build_ext' successfully preprocesses and cythonizes
   with compiler_directives from setup.py's cythonize() call
2. 'setup.py install' internally triggers a rebuild that:
   - Regenerates the preprocessed .pyx files
   - Re-runs cythonize() through Cython.Distutils.build_ext
   - Does NOT apply the compiler_directives from setup.py
   - Fails on the regenerated files missing required directives

New Options Explained:

`--use-pep517`:
  Addresses deprecation warning:
  ```
    DEPRECATION: Building 'rados' using the legacy setup.py bdist_wheel
    mechanism, which will be removed in a future version. pip 25.3 will
    enforce this behaviour change.
  ```
  Uses the modern PEP 517 build backend which:
  - Performs a single build pass with all compiler_directives applied
  - Prevents the implicit rebuild that caused CompileError
  - Future-proofs against pip 25.3+ which will require this

`--no-build-isolation`:
  Ensures that environment variables set by CMake are respected:
  - CC, LDSHARED (compiler toolchain)
  - CPPFLAGS, LDFLAGS (compilation flags)
  - CYTHON_BUILD_DIR, CEPH_LIBDIR (build paths)

  Without this flag, pip would create an isolated build environment
  that ignores these critical build settings.

`--no-deps`:
  Prevents pip from attempting to install Python dependencies listed
  in setup.py's install_requires. All dependencies are managed by
  CMake and the distribution's package manager, not pip.

`--ignore-installed`:
  Addresses installation error when DESTDIR is set:
  ```
    ERROR: Could not install packages due to an OSError: [Errno 13]
    Permission denied: '/usr/lib/python3/dist-packages/rados-2.0.0.egg-info'

    OSError: [Errno 18] Invalid cross-device link:
    '/usr/lib/python3/dist-packages/rados-2.0.0.egg-info' -> '/tmp/pip-uninstall-...'
  ```
  This error occurs because pip detects an existing system installation
  and tries to uninstall it before installing to DESTDIR. With
  --ignore-installed, pip skips the uninstall step and directly installs
  to the DESTDIR staging directory, which is the correct behavior for
  packaging.

Removed Options:

`--install-layout=deb`:
  This Debian-specific patch to 'setup.py install' is no longer needed.
  Modern pip automatically detects the distribution and uses the correct
  layout (dist-packages on Debian, site-packages on RPM distros).

`--single-version-externally-managed`:
  This option was specific to 'setup.py install' to prevent egg
  installation. With pip, this is handled automatically.

`--record /dev/null`:
  No longer needed as pip manages installation records internally.

`egg_info --egg-base`:
  Not needed with pip as metadata is generated automatically during
  the build process.

Not added option:
`--root-user-action=ignore`: not added
  In this change, we installing a python module using pip with
  `fakeroot` before packaging it. But pip warned:
  ```
  Error: WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behavior with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
  ```
  But we use fakeroot on purpose, this option could have been added  to
  silence this warning. But it is not available in all supported pip
  versions. see
  2e1112a814

New environmental variable:
`DEB_PYTHON_INSTALL_LAYOUT=deb` is conditionally applied when packaging
for debian-derivative distributions. As pip does not support
`--install-layout` option. Since debian patches pip so it installs Python
modules into /usr/local/lib instead of /usr/lib where debian dh_install
helper looks for the content to be packaged, so we have to enforce the
debian layout using the environmental variable.

Working Directory Change:

Changed from `CMAKE_CURRENT_SOURCE_DIR` to `CMAKE_CURRENT_BINARY_DIR` to
keep pip's temporary files and logs in the build directory rather than
polluting the source tree.

Additional Dependencies:

Since the build process uses pip and creates a wheel distribution,
we need to add `pip` and `wheel` Python modules as build dependencies.

Python moduels packaging:

- with `--use-pep517`, pip creates .dist-info directoires as per PEP-517
instead of .egg-info, so we need to package the new metadata directory.

Future Improvements

We considered implementing a custom `build_templates` command or using
setuptools' `sub_commands` mechanism to avoid regenerating `*_processed.pyx`
files on every build (tracking dependencies via file modification times or
hash-based checks). However, to keep `setup.py` simple and maintainable,
we've deferred this optimization for future work. The current solution
using `pip install --use-pep517` ensures correct builds without additional
complexity.

This solution works correctly for both Debian and RPM packaging workflows,
both of which use DESTDIR-based staged installations.

Fixes: 719b749846
Signed-off-by: Kefu Chai <k.chai@proxmox.com>
2026-01-29 08:45:50 +08:00

167 lines
6.6 KiB
CMake

include(CMakeParseArguments)
# ensure that we are using the exact python version specified by
# 'WITH_PYTHON3', in case some included 3rd party libraries call
# 'find_package(Python3 ...) without specifying the exact version number. if
# the building host happens to have a higher version of python3, that version
# would be picked up instead by find_package(Python3). and that is not want we
# expect.
find_package(Python3 ${WITH_PYTHON3} EXACT
QUIET
REQUIRED
COMPONENTS Interpreter)
function(distutils_install_module name)
set(py_srcs setup.py README.rst requirements.txt test-requirements.txt bin ${name})
foreach(src ${py_srcs})
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/${src})
list(APPEND py_clone ${CMAKE_CURRENT_BINARY_DIR}/${src})
add_custom_command(
OUTPUT ${src}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${src}
COMMAND ${CMAKE_COMMAND} -E create_symlink ${CMAKE_CURRENT_SOURCE_DIR}/${src} ${src})
endif()
endforeach()
if(NOT TARGET ${name}-clone)
add_custom_target(${name}-clone ALL
DEPENDS ${py_clone})
endif()
cmake_parse_arguments(DU "" "INSTALL_SCRIPT" "" ${ARGN})
install(CODE "
set(options --prefix=${CMAKE_INSTALL_PREFIX})
if(DEFINED ENV{DESTDIR})
if(EXISTS /etc/debian_version)
list(APPEND options --install-layout=deb)
endif()
list(APPEND options
--root=\$ENV{DESTDIR}
--single-version-externally-managed)
endif()
if(NOT \"${DU_INSTALL_SCRIPT}\" STREQUAL \"\")
list(APPEND options --install-script=${DU_INSTALL_SCRIPT})
endif()
execute_process(
COMMAND ${Python3_EXECUTABLE}
setup.py install \${options}
WORKING_DIRECTORY \"${CMAKE_CURRENT_BINARY_DIR}\")")
endfunction(distutils_install_module)
function(distutils_add_cython_module target name src)
get_property(compiler_launcher GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
get_property(link_launcher GLOBAL PROPERTY RULE_LAUNCH_LINK)
# When using ccache, CMAKE_C_COMPILER is ccache executable absolute path
# and the actual C compiler is CMAKE_C_COMPILER_ARG1.
# However with a naive
# set(PY_CC ${compiler_launcher} ${CMAKE_C_COMPILER} ${CMAKE_C_COMPILER_ARG1})
# distutils tries to execve something like "/usr/bin/cmake gcc" and fails.
# Removing the leading whitespace from CMAKE_C_COMPILER_ARG1 helps to avoid
# the failure.
string(STRIP "${CMAKE_C_COMPILER_ARG1}" c_compiler_arg1)
string(STRIP "${CMAKE_CXX_COMPILER_ARG1}" cxx_compiler_arg1)
# Note: no quotes, otherwise distutils will execute "/usr/bin/ccache gcc"
# CMake's implicit conversion between strings and lists is wonderful, isn't it?
set(PY_CFLAGS ${COMPILE_OPTIONS})
cmake_parse_arguments(DU "DISABLE_VTA" "" "" ${ARGN})
if(DU_DISABLE_VTA AND HAS_VTA)
list(APPEND PY_CFLAGS -fno-var-tracking-assignments)
endif()
list(APPEND PY_CPPFLAGS -iquote${CMAKE_SOURCE_DIR}/src/include -w)
# This little bit of magic wipes out __Pyx_check_single_interpreter()
# Note: this is reproduced in distutils_install_cython_module
list(APPEND PY_CPPFLAGS -D'void0=dead_function\(void\)')
list(APPEND PY_CPPFLAGS -D'__Pyx_check_single_interpreter\(ARG\)=ARG\#\#0')
set(PY_CC ${compiler_launcher} ${CMAKE_C_COMPILER} ${c_compiler_arg1})
set(PY_CXX ${compiler_launcher} ${CMAKE_CXX_COMPILER} ${cxx_compiler_arg1})
set(PY_LDSHARED ${link_launcher} ${CMAKE_C_COMPILER} ${c_compiler_arg1} "-shared")
string(REPLACE " " ";" PY_LDFLAGS "${CMAKE_SHARED_LINKER_FLAGS}")
list(APPEND PY_LDFLAGS -L${CMAKE_LIBRARY_OUTPUT_DIRECTORY})
execute_process(COMMAND "${Python3_EXECUTABLE}" -c
"import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))"
RESULT_VARIABLE result
OUTPUT_VARIABLE ext_suffix
ERROR_VARIABLE error
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(NOT result EQUAL 0)
message(FATAL_ERROR "Unable to tell python extension's suffix: ${error}")
endif()
set(output_dir "${CYTHON_MODULE_DIR}/lib.3")
set(setup_py ${CMAKE_CURRENT_SOURCE_DIR}/setup.py)
if(DEFINED ENV{VERBOSE})
set(maybe_verbose --verbose)
endif()
add_custom_command(
OUTPUT ${output_dir}/${name}${ext_suffix}
COMMAND
env
CC="${PY_CC}"
CFLAGS="${PY_CFLAGS}"
CPPFLAGS="${PY_CPPFLAGS}"
CXX="${PY_CXX}"
LDSHARED="${PY_LDSHARED}"
OPT=\"-DNDEBUG -g -fwrapv -O2 -w\"
LDFLAGS="${PY_LDFLAGS}"
CYTHON_BUILD_DIR=${CMAKE_CURRENT_BINARY_DIR}
CEPH_LIBDIR=${CMAKE_LIBRARY_OUTPUT_DIRECTORY}
${Python3_EXECUTABLE} ${setup_py}
build ${maybe_verbose} --build-base ${CYTHON_MODULE_DIR}
--build-platlib ${output_dir}
MAIN_DEPENDENCY ${src}
DEPENDS ${setup_py}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
add_custom_target(${target} ALL
DEPENDS ${output_dir}/${name}${ext_suffix})
endfunction(distutils_add_cython_module)
function(distutils_install_cython_module name)
get_property(compiler_launcher GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
get_property(link_launcher GLOBAL PROPERTY RULE_LAUNCH_LINK)
set(PY_CC "${compiler_launcher} ${CMAKE_C_COMPILER}")
set(PY_LDSHARED "${link_launcher} ${CMAKE_C_COMPILER} -shared")
set(PY_LDFLAGS "${CMAKE_SHARED_LINKER_FLAGS} -L${CMAKE_LIBRARY_OUTPUT_DIRECTORY}")
cmake_parse_arguments(DU "DISABLE_VTA" "" "" ${ARGN})
if(DU_DISABLE_VTA AND HAS_VTA)
set(CFLAG_DISABLE_VTA -fno-var-tracking-assignments)
endif()
if(DEFINED ENV{VERBOSE})
set(maybe_verbose --verbose)
endif()
install(CODE "
set(ENV{CC} \"${PY_CC}\")
set(ENV{LDSHARED} \"${PY_LDSHARED}\")
set(ENV{CPPFLAGS} \"-iquote${CMAKE_SOURCE_DIR}/src/include
-D'void0=dead_function\(void\)' \
-D'__Pyx_check_single_interpreter\(ARG\)=ARG\#\#0' \
${CFLAG_DISABLE_VTA}\")
set(ENV{LDFLAGS} \"${PY_LDFLAGS}\")
set(ENV{CYTHON_BUILD_DIR} \"${CMAKE_CURRENT_BINARY_DIR}\")
set(ENV{CEPH_LIBDIR} \"${CMAKE_LIBRARY_OUTPUT_DIRECTORY}\")
set(options
--prefix=${CMAKE_INSTALL_PREFIX}
--use-pep517
--no-build-isolation
--no-deps
--ignore-installed)
if(DEFINED ENV{DESTDIR})
if(EXISTS /etc/debian_version)
list(APPEND env_vars \"DEB_PYTHON_INSTALL_LAYOUT=deb\")
endif()
list(APPEND options --root=\$ENV{DESTDIR})
else()
list(APPEND options --root=/)
endif()
execute_process(
COMMAND env \${env_vars}
${Python3_EXECUTABLE} -m pip install
\${options}
${maybe_verbose}
${CMAKE_CURRENT_SOURCE_DIR}
WORKING_DIRECTORY \"${CMAKE_CURRENT_BINARY_DIR}\"
RESULT_VARIABLE install_res)
if(NOT \"\${install_res}\" STREQUAL 0)
message(FATAL_ERROR \"Failed to build and install ${name} python module\")
endif()
")
endfunction(distutils_install_cython_module)