diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6c54e8c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +build/ +install/ +log/ +docker \ No newline at end of file diff --git a/ament_cmake_python/ament_cmake_python-extras.cmake b/ament_cmake_python/ament_cmake_python-extras.cmake index d8825818..bd239c4a 100644 --- a/ament_cmake_python/ament_cmake_python-extras.cmake +++ b/ament_cmake_python/ament_cmake_python-extras.cmake @@ -79,6 +79,17 @@ print(os.path.relpath(sysconfig.get_path('purelib', **kwargs), start='${CMAKE_IN endif() endmacro() +macro(_ament_cmake_python_register_extension_hook) + if(NOT DEFINED AMENT_CMAKE_PYTHON_EXTENSION_REGISTERED) + set(AMENT_CMAKE_PYTHON_EXTENSION_REGISTERED TRUE) + + ament_register_extension( + "ament_package" + "ament_cmake_python" + "ament_python_install_registered_packages.cmake") + endif() +endmacro() + include("${ament_cmake_python_DIR}/ament_python_install_module.cmake") include("${ament_cmake_python_DIR}/ament_python_install_package.cmake") include("${ament_cmake_python_DIR}/ament_get_python_install_dir.cmake") diff --git a/ament_cmake_python/cmake/ament_python_install_package.cmake b/ament_cmake_python/cmake/ament_python_install_package.cmake index 25a08092..cdbe86b7 100644 --- a/ament_cmake_python/cmake/ament_python_install_package.cmake +++ b/ament_cmake_python/cmake/ament_python_install_package.cmake @@ -35,6 +35,7 @@ # :type SKIP_COMPILE: option # macro(ament_python_install_package) + _ament_cmake_python_register_extension_hook() _ament_cmake_python_register_environment_hook() _ament_cmake_python_install_package(${ARGN}) endmacro() @@ -83,129 +84,35 @@ function(_ament_cmake_python_install_package package_name) set(ARG_DESTINATION ${PYTHON_INSTALL_DIR}) endif() - set(build_dir "${CMAKE_CURRENT_BINARY_DIR}/ament_cmake_python/${package_name}") - - string(CONFIGURE "\ -from setuptools import find_packages -from setuptools import setup - -setup( - name='${package_name}', - version='${ARG_VERSION}', - packages=find_packages( - include=('${package_name}', '${package_name}.*')), -) -" setup_py_content) - - file(GENERATE - OUTPUT "${build_dir}/setup.py" - CONTENT "${setup_py_content}" - ) - - if(AMENT_CMAKE_SYMLINK_INSTALL) - add_custom_target( - ament_cmake_python_symlink_${package_name} - COMMAND ${CMAKE_COMMAND} -E create_symlink - "${ARG_PACKAGE_DIR}" "${build_dir}/${package_name}" - ) - set(egg_dependencies ament_cmake_python_symlink_${package_name}) - - if(ARG_SETUP_CFG) - add_custom_target( - ament_cmake_python_symlink_${package_name}_setup - COMMAND ${CMAKE_COMMAND} -E create_symlink - "${ARG_SETUP_CFG}" "${build_dir}/setup.cfg" - ) - list(APPEND egg_dependencies ament_cmake_python_symlink_${package_name}_setup) - endif() + get_property(_pkgs GLOBAL PROPERTY AMENT_CMAKE_PYTHON_PKGS) + list(FIND _pkgs "${package_name}" _idx) + if(_idx EQUAL -1) + set_property(GLOBAL APPEND PROPERTY AMENT_CMAKE_PYTHON_PKGS "${package_name}") else() - add_custom_target( - ament_cmake_python_copy_${package_name} - COMMAND ${CMAKE_COMMAND} -E copy_directory - "${ARG_PACKAGE_DIR}" "${build_dir}/${package_name}" - ) - set(egg_dependencies ament_cmake_python_copy_${package_name}) - - if(ARG_SETUP_CFG) - add_custom_target( - ament_cmake_python_copy_${package_name}_setup - COMMAND ${CMAKE_COMMAND} -E copy - "${ARG_SETUP_CFG}" "${build_dir}/setup.cfg" - ) - list(APPEND egg_dependencies ament_cmake_python_copy_${package_name}_setup) - endif() + message(STATUS "ament_python_install_package: extending '${package_name}'") endif() - # Technically, we should call find_package(Python3) first to ensure that Python3::Interpreter - # is available. But we skip this here because this macro requires ament_cmake, and ament_cmake - # calls find_package(Python3) for us. - get_executable_path(python_interpreter Python3::Interpreter BUILD) - - add_custom_target( - ament_cmake_python_build_${package_name}_egg ALL - COMMAND ${python_interpreter} setup.py egg_info - WORKING_DIRECTORY "${build_dir}" - DEPENDS ${egg_dependencies} - ) - - set(python_version "py${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}") - - set(egg_name "${package_name}") - set(egg_install_name "${egg_name}-${ARG_VERSION}") - set(egg_install_name "${egg_install_name}-${python_version}") - - install( - DIRECTORY "${build_dir}/${egg_name}.egg-info/" - DESTINATION "${ARG_DESTINATION}/${egg_install_name}.egg-info" - ) - - if(ARG_SCRIPTS_DESTINATION) - file(MAKE_DIRECTORY "${build_dir}/scripts") # setup.py may or may not create it - - add_custom_target( - ament_cmake_python_build_${package_name}_scripts ALL - COMMAND ${python_interpreter} setup.py install_scripts -d scripts - WORKING_DIRECTORY "${build_dir}" - DEPENDS ${egg_dependencies} - ) - - if(NOT AMENT_CMAKE_SYMLINK_INSTALL) - # Not needed for nor supported by symlink installs - set(_extra_install_args USE_SOURCE_PERMISSIONS) - endif() - - install( - DIRECTORY "${build_dir}/scripts/" - DESTINATION "${ARG_SCRIPTS_DESTINATION}/" - ${_extra_install_args} - ) + get_property(_dirs GLOBAL PROPERTY AMENT_CMAKE_PYTHON_${package_name}_PACKAGE_DIRS) + list(FIND _dirs "${ARG_PACKAGE_DIR}" _didx) + if(_didx EQUAL -1) + set_property(GLOBAL APPEND PROPERTY AMENT_CMAKE_PYTHON_${package_name}_PACKAGE_DIRS "${ARG_PACKAGE_DIR}") + else() + message(WARNING "duplicate PACKAGE_DIR for '${package_name}', skipping") endif() - install( - DIRECTORY "${ARG_PACKAGE_DIR}/" - DESTINATION "${ARG_DESTINATION}/${package_name}" - PATTERN "*.pyc" EXCLUDE - PATTERN "__pycache__" EXCLUDE - ) - - if(NOT ARG_SKIP_COMPILE) - get_executable_path(python_interpreter_config Python3::Interpreter CONFIGURE) - # compile Python files - install(CODE - "execute_process( - COMMAND - \"${python_interpreter_config}\" \"-m\" \"compileall\" - \"${CMAKE_INSTALL_PREFIX}/${ARG_DESTINATION}/${package_name}\" - )" - ) - endif() + _ament_cmake_python_override(SKIP_COMPILE) + _ament_cmake_python_override(VERSION) + _ament_cmake_python_override(SETUP_CFG) + _ament_cmake_python_override(DESTINATION) + _ament_cmake_python_override(SCRIPTS_DESTINATION) +endfunction() - if(package_name IN_LIST AMENT_CMAKE_PYTHON_INSTALL_INSTALLED_NAMES) - message(FATAL_ERROR - "ament_python_install_package() a Python module file or package with " - "the same name '${package_name}' has been installed before") +macro(_ament_cmake_python_override param) + set(_prop "AMENT_CMAKE_PYTHON_${package_name}_${param}") + set(_val "${ARG_${param}}") + get_property(_old_val GLOBAL PROPERTY ${_prop}) + if(_old_val AND NOT _old_val STREQUAL "${_val}") + message(WARNING "${param} for '${package_name}' changed from '${_old_val}' to '${_val}'") endif() - list(APPEND AMENT_CMAKE_PYTHON_INSTALL_INSTALLED_NAMES "${package_name}") - set(AMENT_CMAKE_PYTHON_INSTALL_INSTALLED_NAMES - "${AMENT_CMAKE_PYTHON_INSTALL_INSTALLED_NAMES}" PARENT_SCOPE) -endfunction() + set_property(GLOBAL PROPERTY ${_prop} "${_val}") +endmacro() diff --git a/ament_cmake_python/cmake/ament_python_install_registered_packages.cmake b/ament_cmake_python/cmake/ament_python_install_registered_packages.cmake new file mode 100644 index 00000000..379e9321 --- /dev/null +++ b/ament_cmake_python/cmake/ament_python_install_registered_packages.cmake @@ -0,0 +1,157 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +function(ament_cmake_python_install_registered_packages) + get_property(_pkgs GLOBAL PROPERTY AMENT_CMAKE_PYTHON_PKGS) + foreach(pkg IN LISTS _pkgs) + _ament_cmake_python_install_package_impl(${pkg}) + endforeach() +endfunction() + +function(_ament_cmake_python_install_package_impl package_name) + foreach(_prop IN ITEMS SKIP_COMPILE VERSION SETUP_CFG DESTINATION SCRIPTS_DESTINATION PACKAGE_DIRS) + get_property(_${_prop} GLOBAL PROPERTY AMENT_CMAKE_PYTHON_${package_name}_${_prop}) + endforeach() + + _ament_cmake_python_prepare_build(${package_name}) + _ament_cmake_python_copy_build_files(${package_name}) + + # Technically, we should call find_package(Python3) first to ensure that Python3::Interpreter + # is available. But we skip this here because this macro requires ament_cmake, and ament_cmake + # calls find_package(Python3) for us. + get_executable_path(python_interpreter Python3::Interpreter BUILD) + + _ament_cmake_python_generate_egg(${package_name}) + + if(_SCRIPTS_DESTINATION) + _ament_cmake_python_install_scripts(${package_name}) + endif() + + _ament_cmake_python_install_sources(${package_name}) + + if(NOT _SKIP_COMPILE) + _ament_cmake_python_byte_compile(${package_name}) + endif() + +endfunction() + +macro(_ament_cmake_python_prepare_build package_name) + set(_build_dir "${CMAKE_CURRENT_BINARY_DIR}/ament_cmake_python/${package_name}") + + string(CONFIGURE "\ +from setuptools import find_packages +from setuptools import setup + +setup( + name='${package_name}', + version='${_VERSION}', + packages=find_packages( + include=('${package_name}', '${package_name}.*')), +) +" setup_py_content) + + file(GENERATE + OUTPUT "${_build_dir}/setup.py" + CONTENT "${setup_py_content}" + ) + +endmacro() + +macro(_ament_cmake_python_copy_build_files package_name) + set(_sync_target "ament_cmake_python_sync_${package_name}") + + add_custom_target(${_sync_target} DEPENDS ${_PACKAGE_DIRS} ${_SETUP_CFG}) + + foreach(_dir IN LISTS _PACKAGE_DIRS) + add_custom_command(TARGET ${_sync_target} + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_dir}" "${_build_dir}/${package_name}" + ) + endforeach() + + if(_SETUP_CFG) + add_custom_command(TARGET ${_sync_target} + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_SETUP_CFG}" "${_build_dir}/setup.cfg" + ) + endif() + +endmacro() + +macro(_ament_cmake_python_generate_egg package_name) + add_custom_target( + ament_cmake_python_build_${package_name}_egg ALL + COMMAND ${python_interpreter} setup.py egg_info + WORKING_DIRECTORY "${_build_dir}" + DEPENDS ${_sync_target} + ) + + set(python_version "py${Python3_VERSION_MAJOR}.${Python3_VERSION_MINOR}") + + set(egg_name "${package_name}") + set(egg_install_name "${egg_name}-${_VERSION}") + set(egg_install_name "${egg_install_name}-${python_version}") + + install( + DIRECTORY "${_build_dir}/${egg_name}.egg-info/" + DESTINATION "${_DESTINATION}/${egg_install_name}.egg-info" + ) +endmacro() + +macro(_ament_cmake_python_install_scripts package_name) + file(MAKE_DIRECTORY "${_build_dir}/scripts") # setup.py may or may not create it + + add_custom_target( + ament_cmake_python_build_${package_name}_scripts ALL + COMMAND ${python_interpreter} setup.py install_scripts -d scripts + WORKING_DIRECTORY "${_build_dir}" + DEPENDS ${_sync_target} + ) + + if(NOT AMENT_CMAKE_SYMLINK_INSTALL) + # Not needed for nor supported by symlink installs + set(_extra_install_args USE_SOURCE_PERMISSIONS) + endif() + + install( + DIRECTORY "${_build_dir}/scripts/" + DESTINATION "${_SCRIPTS_DESTINATION}/" + ${_extra_install_args} + ) +endmacro() + +macro(_ament_cmake_python_install_sources package_name) + foreach(_dir IN LISTS _PACKAGE_DIRS) + install( + DIRECTORY "${_dir}/" + DESTINATION "${_DESTINATION}/${package_name}" + PATTERN "*.pyc" EXCLUDE + PATTERN "__pycache__" EXCLUDE + ) + endforeach() +endmacro() + +macro(_ament_cmake_python_byte_compile package_name) + get_executable_path(python_interpreter_config Python3::Interpreter CONFIGURE) + # compile Python files + install(CODE + "execute_process( + COMMAND + \"${python_interpreter_config}\" \"-m\" \"compileall\" + \"${CMAKE_INSTALL_PREFIX}/${_DESTINATION}/${package_name}\" + )" + ) +endmacro() + +ament_cmake_python_install_registered_packages() diff --git a/ament_cmake_python_test/.gitignore b/ament_cmake_python_test/.gitignore new file mode 100644 index 00000000..c678a5e1 --- /dev/null +++ b/ament_cmake_python_test/.gitignore @@ -0,0 +1,3 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] diff --git a/ament_cmake_python_test/CMakeLists.txt b/ament_cmake_python_test/CMakeLists.txt new file mode 100644 index 00000000..25cc690b --- /dev/null +++ b/ament_cmake_python_test/CMakeLists.txt @@ -0,0 +1,31 @@ +cmake_minimum_required(VERSION 3.12) + +project(ament_cmake_python_test NONE) + +find_package(ament_cmake_core REQUIRED) + +if(BUILD_TESTING) + find_package(ament_cmake_pytest REQUIRED) + find_package(ament_cmake_python REQUIRED) + + ament_python_install_package( + ament_python_test_package + PACKAGE_DIR test/ament_python_test_package + ) + ament_python_install_package( + ament_python_test_package_overlay + PACKAGE_DIR test/ament_python_test_package + ) + ament_python_install_package( + ament_python_test_package_overlay + PACKAGE_DIR test/ament_python_test_package_overlay + ) + + ament_add_pytest_test( + test_ament_python_install_package + test/test_ament_python_install_package.py + ENV TEST_PACKAGE_INSTALL_DIR=${CMAKE_INSTALL_PREFIX}/${PYTHON_INSTALL_DIR} + ) +endif() + +ament_package() diff --git a/ament_cmake_python_test/README.md b/ament_cmake_python_test/README.md new file mode 100644 index 00000000..33659a87 --- /dev/null +++ b/ament_cmake_python_test/README.md @@ -0,0 +1,28 @@ +# ament_cmake_python + +This package adds functions for installing Python packages and modules in CMake. + +## API + +Calling `find_package(ament_cmake_python)` will make the following API available. + +### ament_get_python_install_dir + +The CMake function [`ament_get_python_install_dir`](cmake/ament_get_python_install_dir.cmake) gets the path Python packages will be installed to. +The path is always relative to `CMAKE_INSTALL_PREFIX`. + +The path can be customized by setting `PYTHON_INSTALL_DIR` on the command line. +It must be a relative path. +For example, the cmake command bellow would cause Python code to be installed to `${CMAKE_INSTALL_PREFIX}/foobar/site-packages`. + +```console +$ cmake ../path/to/package/using/ament_cmake_python -DPYTHON_INSTALL_DIR=foobar/site-packages +``` + +### ament_python_install_module + +The CMake macro [`ament_python_install_module`](cmake/ament_python_install_module.cmake) will install a single Python module to the Python install directory. + +### ament_python_install_package + +The CMake macro [`ament_python_install_package`](cmake/ament_python_install_package.cmake) will install a Python package and all subpackages to the Python install directory. diff --git a/ament_cmake_python_test/package.xml b/ament_cmake_python_test/package.xml new file mode 100644 index 00000000..c14f754c --- /dev/null +++ b/ament_cmake_python_test/package.xml @@ -0,0 +1,24 @@ + + + + ament_cmake_python_test + 1.0.0 + Testing of ament_cmake_python + + Chris Lalancette + + Apache License 2.0 + + Dirk Thomas + Michel Hidalgo + + ament_cmake_core + ament_cmake_pytest + ament_cmake_python + + ament_cmake_core + + + ament_cmake + + diff --git a/ament_cmake_python_test/test/ament_python_test_package/__init__.py b/ament_cmake_python_test/test/ament_python_test_package/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ament_cmake_python_test/test/ament_python_test_package/main.py b/ament_cmake_python_test/test/ament_python_test_package/main.py new file mode 100644 index 00000000..624ef4fe --- /dev/null +++ b/ament_cmake_python_test/test/ament_python_test_package/main.py @@ -0,0 +1,3 @@ +class Bar: + def __init__() -> None: + pass diff --git a/ament_cmake_python_test/test/ament_python_test_package_overlay/__init__.py b/ament_cmake_python_test/test/ament_python_test_package_overlay/__init__.py new file mode 100644 index 00000000..50fb31a6 --- /dev/null +++ b/ament_cmake_python_test/test/ament_python_test_package_overlay/__init__.py @@ -0,0 +1 @@ +from subdir.utils import foo diff --git a/ament_cmake_python_test/test/ament_python_test_package_overlay/subdir/__init__.py b/ament_cmake_python_test/test/ament_python_test_package_overlay/subdir/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ament_cmake_python_test/test/ament_python_test_package_overlay/subdir/utils.py b/ament_cmake_python_test/test/ament_python_test_package_overlay/subdir/utils.py new file mode 100644 index 00000000..256ce6d1 --- /dev/null +++ b/ament_cmake_python_test/test/ament_python_test_package_overlay/subdir/utils.py @@ -0,0 +1,2 @@ +def foo() -> None: + pass diff --git a/ament_cmake_python_test/test/test_ament_python_install_package.py b/ament_cmake_python_test/test/test_ament_python_install_package.py new file mode 100644 index 00000000..e7ed0683 --- /dev/null +++ b/ament_cmake_python_test/test/test_ament_python_install_package.py @@ -0,0 +1,24 @@ +from pathlib import Path +import os +import filecmp +import shutil + +INSTALL_DIR = Path(os.environ["TEST_PACKAGE_INSTALL_DIR"]) +TEST_DIR = Path(__file__).parent +AMENT_PYTHON_TEST_PACKAGE = "ament_python_test_package" +AMENT_PYTHON_TEST_PACKAGE_OVERLAY = AMENT_PYTHON_TEST_PACKAGE + "_overlay" + +def test_ament_python_test_package() -> None: + assert not filecmp.dircmp( + TEST_DIR / AMENT_PYTHON_TEST_PACKAGE, + INSTALL_DIR / AMENT_PYTHON_TEST_PACKAGE + ).diff_files + +def test_ament_python_test_package_with_overlay(tmpdir) -> None: + shutil.copytree(TEST_DIR / AMENT_PYTHON_TEST_PACKAGE, tmpdir, dirs_exist_ok=True) + shutil.copytree(TEST_DIR / AMENT_PYTHON_TEST_PACKAGE_OVERLAY, tmpdir, dirs_exist_ok=True) + assert not filecmp.dircmp(tmpdir, INSTALL_DIR / AMENT_PYTHON_TEST_PACKAGE_OVERLAY).diff_files + assert not filecmp.dircmp( + tmpdir / "subdir", + INSTALL_DIR / AMENT_PYTHON_TEST_PACKAGE_OVERLAY / "subdir" + ).diff_files