# PythonVenv.cmake — project-wide Python venv shared by every component that # ships Python tooling (currently MsgUtils; LogReader will join later). # # Why a venv at all: PEP 668 distros (Debian/Ubuntu with Python 3.11+, etc.) # forbid `pip install` into the system or user site-packages. The venv lets # editable installs work without `--break-system-packages` and without # polluting the developer's machine. # # Why project-level (and not per-component): more than one component needs a # Python interpreter to install into; pointing each at the same venv keeps # every installed package mutually importable, avoids duplicate venv setup # code in every CMakeLists.txt, and makes "where do my Python deps live" # answerable in one place. # # Usage: # include(cmake/PythonVenv.cmake) # creates ${PROJECT_PYTHON} # register_python_package() # pip install -e the given pyproject # # Re-installs only when the package's pyproject.toml changes — keyed via a # build-tree stamp file, so reconfigures stay fast. if(DEFINED PROJECT_PYTHON_INCLUDED) return() endif() set(PROJECT_PYTHON_INCLUDED TRUE) find_package(Python3 REQUIRED COMPONENTS Interpreter) set(PROJECT_VENV_DIR ${CMAKE_BINARY_DIR}/.venv CACHE INTERNAL "Project Python venv root") set(PROJECT_PYTHON ${PROJECT_VENV_DIR}/bin/python CACHE INTERNAL "Project venv python interpreter") # Probe pip inside the venv. A half-created venv (missing python3-venv on the # host) can have python but no pip — we want to catch that and report it # clearly rather than failing on the first `pip install`. execute_process( COMMAND ${PROJECT_PYTHON} -m pip --version RESULT_VARIABLE _venv_pip_ok OUTPUT_QUIET ERROR_QUIET ) if(NOT _venv_pip_ok EQUAL 0) message(STATUS "Creating project Python venv at ${PROJECT_VENV_DIR}") file(REMOVE_RECURSE ${PROJECT_VENV_DIR}) execute_process( COMMAND ${Python3_EXECUTABLE} -m venv ${PROJECT_VENV_DIR} RESULT_VARIABLE _venv_result OUTPUT_VARIABLE _venv_output ERROR_VARIABLE _venv_output ) if(NOT _venv_result EQUAL 0 OR NOT EXISTS ${PROJECT_PYTHON}) message(FATAL_ERROR "Failed to create venv at ${PROJECT_VENV_DIR}.\n" "Install the venv module (e.g. 'sudo apt install python3-venv' or 'python3-full' on Debian/Ubuntu, " "matching your Python version).\n" "Output:\n${_venv_output}") endif() execute_process( COMMAND ${PROJECT_PYTHON} -m pip --version RESULT_VARIABLE _venv_pip_ok OUTPUT_QUIET ERROR_QUIET ) if(NOT _venv_pip_ok EQUAL 0) message(FATAL_ERROR "Venv at ${PROJECT_VENV_DIR} was created but pip is not available inside it. " "Install the matching python3-venv package and reconfigure.") endif() endif() # Editable installs via pyproject.toml require pip >= 21.3. Upgrade once per # configure; cheap when already up-to-date. execute_process( COMMAND ${PROJECT_PYTHON} -m pip install --upgrade pip --quiet RESULT_VARIABLE _pip_upgrade_result ) if(NOT _pip_upgrade_result EQUAL 0) message(FATAL_ERROR "Failed to upgrade pip in project venv (pip returned ${_pip_upgrade_result})") endif() # register_python_package() # # Installs the pyproject at editable into the project venv. # Re-runs only when /pyproject.toml's mtime moves past a stamp # file in the build tree, so reconfiguring on an unchanged tree is a no-op. # Stamps are namespaced by the leaf directory name so two pyprojects in # different directories don't clobber each other. function(register_python_package _pkg_dir) if(NOT IS_ABSOLUTE "${_pkg_dir}") message(FATAL_ERROR "register_python_package: path must be absolute: ${_pkg_dir}") endif() set(_pyproject "${_pkg_dir}/pyproject.toml") if(NOT EXISTS "${_pyproject}") message(FATAL_ERROR "register_python_package: no pyproject.toml at ${_pkg_dir}") endif() get_filename_component(_leaf "${_pkg_dir}" NAME) set(_stamp "${CMAKE_BINARY_DIR}/python-venv-stamps/${_leaf}.installed") # Compare stamp mtime to the pyproject's. IS_NEWER_THAN treats a missing # stamp as "older", so first configure always installs. if("${_pyproject}" IS_NEWER_THAN "${_stamp}") message(STATUS "Installing Python package ${_leaf} into project venv") execute_process( COMMAND ${PROJECT_PYTHON} -m pip install -e ${_pkg_dir} --quiet RESULT_VARIABLE _pip_result ) if(NOT _pip_result EQUAL 0) message(FATAL_ERROR "Failed to install ${_leaf} into project venv (pip returned ${_pip_result})") endif() file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/python-venv-stamps") file(TOUCH "${_stamp}") endif() endfunction()