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..26c4e87d 100644 --- a/ament_cmake_python/cmake/ament_python_install_package.cmake +++ b/ament_cmake_python/cmake/ament_python_install_package.cmake @@ -13,7 +13,14 @@ # limitations under the License. # -# Install a Python package (and its recursive subpackages) +# Install a Python package (and its recursive subpackages). +# +# Can be called multiple times with the same package name to merge +# multiple source directories into a single installed package. +# When called more than once, source directories are merged at build time +# and the last call's parameters take precedence (last registered +# directory wins on file conflicts). Callers providing build-time-generated +# files should pass DEPENDS to ensure generation completes before the merge. # # :param package_name: the Python package name # :type package_name: string @@ -33,15 +40,19 @@ # :type SCRIPTS_DESTINATION: string # :param SKIP_COMPILE: if set do not byte-compile the installed package # :type SKIP_COMPILE: option +# :param DEPENDS: build targets that must complete before +# the package files are synced to the build directory +# :type DEPENDS: list of strings # macro(ament_python_install_package) + _ament_cmake_python_register_extension_hook() _ament_cmake_python_register_environment_hook() _ament_cmake_python_install_package(${ARGN}) endmacro() function(_ament_cmake_python_install_package package_name) cmake_parse_arguments( - ARG "SKIP_COMPILE" "PACKAGE_DIR;VERSION;SETUP_CFG;DESTINATION;SCRIPTS_DESTINATION" "" ${ARGN}) + ARG "SKIP_COMPILE" "PACKAGE_DIR;VERSION;SETUP_CFG;DESTINATION;SCRIPTS_DESTINATION" "DEPENDS" ${ARGN}) if(ARG_UNPARSED_ARGUMENTS) message(FATAL_ERROR "ament_python_install_package() called with unused " "arguments: ${ARG_UNPARSED_ARGUMENTS}") @@ -83,129 +94,19 @@ 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() - 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} - ) - 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}\" - )" - ) + message(STATUS "ament_python_install_package: extending '${package_name}'") endif() - 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") - 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) + set_property(GLOBAL PROPERTY AMENT_CMAKE_PYTHON_${package_name}_SKIP_COMPILE "${ARG_SKIP_COMPILE}") + set_property(GLOBAL PROPERTY AMENT_CMAKE_PYTHON_${package_name}_VERSION "${ARG_VERSION}") + set_property(GLOBAL PROPERTY AMENT_CMAKE_PYTHON_${package_name}_SETUP_CFG "${ARG_SETUP_CFG}") + set_property(GLOBAL PROPERTY AMENT_CMAKE_PYTHON_${package_name}_DESTINATION "${ARG_DESTINATION}") + set_property(GLOBAL PROPERTY AMENT_CMAKE_PYTHON_${package_name}_SCRIPTS_DESTINATION "${ARG_SCRIPTS_DESTINATION}") + set_property(GLOBAL APPEND PROPERTY AMENT_CMAKE_PYTHON_${package_name}_DEPENDS ${ARG_DEPENDS}) + set_property(GLOBAL APPEND PROPERTY AMENT_CMAKE_PYTHON_${package_name}_PACKAGE_DIRS "${ARG_PACKAGE_DIR}") endfunction() 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..305d92f9 --- /dev/null +++ b/ament_cmake_python/cmake/ament_python_install_registered_packages.cmake @@ -0,0 +1,180 @@ +# 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 DEPENDS) + 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}") + + set(_sync_commands) + foreach(_dir IN LISTS _PACKAGE_DIRS) + list(APPEND _sync_commands + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_dir}" "${_build_dir}/${package_name}") + endforeach() + + if(_SETUP_CFG) + list(APPEND _sync_commands + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_SETUP_CFG}" "${_build_dir}/setup.cfg") + endif() + + add_custom_target(${_sync_target} ${_sync_commands}) + + if(_DEPENDS) + add_dependencies(${_sync_target} ${_DEPENDS}) + 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) + list(LENGTH _PACKAGE_DIRS _num_dirs) + if(_num_dirs EQUAL 1) + # For single dir we install from source to maintain the original behavior + install( + DIRECTORY "${_PACKAGE_DIRS}/" + DESTINATION "${_DESTINATION}/${package_name}" + PATTERN "*.pyc" EXCLUDE + PATTERN "__pycache__" EXCLUDE + ) + elseif(AMENT_CMAKE_SYMLINK_INSTALL) + set(_DIRS_TO_INSTALL "${_PACKAGE_DIRS}") + list(TRANSFORM _DIRS_TO_INSTALL APPEND "/") + foreach(_dir IN LISTS _DIRS_TO_INSTALL) + install( + DIRECTORY "${_dir}" + DESTINATION "${_DESTINATION}/${package_name}" + PATTERN "*.pyc" EXCLUDE + PATTERN "__pycache__" EXCLUDE + ) + endforeach() + else() + install( + DIRECTORY "${_build_dir}/${package_name}/" + DESTINATION "${_DESTINATION}/${package_name}" + PATTERN "*.pyc" EXCLUDE + PATTERN "__pycache__" EXCLUDE + ) + endif() +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/package.xml b/ament_cmake_python/package.xml index 00b98ffc..f59c8381 100644 --- a/ament_cmake_python/package.xml +++ b/ament_cmake_python/package.xml @@ -13,7 +13,6 @@ Michel Hidalgo ament_cmake_core - ament_cmake_core diff --git a/ament_cmake_python_test/CMakeLists.txt b/ament_cmake_python_test/CMakeLists.txt new file mode 100644 index 00000000..671fe398 --- /dev/null +++ b/ament_cmake_python_test/CMakeLists.txt @@ -0,0 +1,54 @@ +cmake_minimum_required(VERSION 3.12) + +project(ament_cmake_python_test) + +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_python_install_package( + ament_python_test_package_no_compile + PACKAGE_DIR test/ament_python_test_package + SKIP_COMPILE + ) + + ament_python_install_package( + ament_python_test_package_versioned + PACKAGE_DIR test/ament_python_test_package + VERSION 1.2.3 + ) + + set(_generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated_pkg") + file(WRITE "${_generated_dir}/__init__.py" "generated = True\n") + add_custom_target(generate_test_files + COMMAND ${CMAKE_COMMAND} -E touch "${_generated_dir}/_generated_marker.py" + ) + ament_python_install_package( + ament_python_test_package_generated + PACKAGE_DIR "${_generated_dir}" + DEPENDS generate_test_files + ) + + 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/package.xml b/ament_cmake_python_test/package.xml new file mode 100644 index 00000000..676473ce --- /dev/null +++ b/ament_cmake_python_test/package.xml @@ -0,0 +1,21 @@ + + + + ament_cmake_python_test + 0.0.0 + Test package for ament_cmake_python. + + Nadav Elkabets + + Apache License 2.0 + + 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..5e3944c4 --- /dev/null +++ b/ament_cmake_python_test/test/ament_python_test_package/main.py @@ -0,0 +1,3 @@ +class Bar: + def __init__(self) -> 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..8fa4805b --- /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/main.py b/ament_cmake_python_test/test/ament_python_test_package_overlay/main.py new file mode 100644 index 00000000..1589c95c --- /dev/null +++ b/ament_cmake_python_test/test/ament_python_test_package_overlay/main.py @@ -0,0 +1,5 @@ +class Bar: + """Overlay version.""" + + def __init__(self) -> None: + pass 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..a69b3617 --- /dev/null +++ b/ament_cmake_python_test/test/test_ament_python_install_package.py @@ -0,0 +1,69 @@ +from pathlib import Path +import filecmp +import os +import shutil + +INSTALL_DIR = Path(os.environ["TEST_PACKAGE_INSTALL_DIR"]) +TEST_DIR = Path(__file__).parent +PKG = "ament_python_test_package" +PKG_OVERLAY = PKG + "_overlay" +PKG_NO_COMPILE = PKG + "_no_compile" +PKG_VERSIONED = PKG + "_versioned" +PKG_GENERATED = PKG + "_generated" + + +def _assert_dirs_match(expected, actual): + """Recursively assert two directories have identical contents.""" + cmp = filecmp.dircmp(expected, actual) + assert not cmp.diff_files + assert not cmp.left_only + assert not cmp.right_only + for subdir in cmp.common_dirs: + if subdir == "__pycache__": + continue + _assert_dirs_match(Path(expected) / subdir, Path(actual) / subdir) + + +def test_single_package(): + """Single package dir installs all files correctly.""" + _assert_dirs_match(TEST_DIR / PKG, INSTALL_DIR / PKG) + + +def test_overlay_merges_files(tmp_path): + """Two package dirs merge correctly, last dir wins on conflicts.""" + merged = tmp_path / "merged" + shutil.copytree(TEST_DIR / PKG, merged) + shutil.copytree(TEST_DIR / PKG_OVERLAY, merged, dirs_exist_ok=True) + _assert_dirs_match(merged, INSTALL_DIR / PKG_OVERLAY) + + +def test_skip_compile(): + """SKIP_COMPILE prevents .pyc generation.""" + pkg_dir = INSTALL_DIR / PKG_NO_COMPILE + assert pkg_dir.exists() + assert not list(pkg_dir.rglob("*.pyc")) + + +def test_default_compiles(): + """Default behavior produces .pyc files.""" + pkg_dir = INSTALL_DIR / PKG + assert list(pkg_dir.rglob("*.pyc")) + + +def test_egg_info(): + """Egg-info is generated.""" + egg_dirs = list(INSTALL_DIR.glob("*.egg-info")) + assert egg_dirs + + +def test_explicit_version(): + """Explicit VERSION propagates to egg-info.""" + egg_dirs = list(INSTALL_DIR.glob("*versioned*1.2.3*.egg-info")) + assert len(egg_dirs) == 1 + + +def test_depends_generates_files(): + """DEPENDS ensures build-time generated files are copied by sync.""" + pkg_dir = INSTALL_DIR / PKG_GENERATED + assert pkg_dir.exists() + assert (pkg_dir / "_generated_marker.py").exists()