diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4a55408b112779261f67d6824e861a80d0978d68..bf1ae8878f8cf905afb4d332b4872f3983b385b5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -69,7 +69,7 @@ mac_x64: - cmake .. -DCMAKE_PREFIX_PATH="$PYPLAT;$OPTDIR;$QTDIR/lib/cmake" -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DBA_APPLE_BUNDLE=ON -DCMAKE_OSX_DEPLOYMENT_TARGET=11 -DCMAKE_INSTALL_PREFIX=/tmp/ba -DBA_PY_PACKAGE=ON - make -j$NPROC - ctest -j3 --output-on-failure - - zsh var/mk_pypack_macos.zsh + - python3 var/mac_py_package.py - cpack -B ./installer . artifacts: &mac_artifacts paths: diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 3dac630ba210a35ea2c0c52417898d47c74d0f45..4f90cd016f1cca6167b2f9ddfed554d80235732c 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -116,8 +116,12 @@ if(BA_APPLE_BUNDLE) RENAME qt.conf) # adjust MacOS bundle - set(MACPKG_ROOT_DIR "${CMAKE_INSTALL_PREFIX}/${destination_prefix}") - set(MACPKG_MAIN_EXE "${executable_name}") + get_property(exe_dir TARGET ${executable_name} PROPERTY RUNTIME_OUTPUT_DIRECTORY) + set(MACPKG_MAIN_EXE "${exe_dir}/${executable_name}.app/Contents/MacOS/${executable_name}") + + # list of BornAgain core libraries + set(MACPKG_CORE_LIBS "$CACHE{BornAgain_LIBRARIES}") + # list of required Qt plugins (paths relative to Qt root dir) set(MACPKG_QT_PLUGINS_RELDIR "${QT6_INSTALL_PLUGINS}") set(MACPKG_QT_PLUGINS @@ -130,11 +134,11 @@ if(BA_APPLE_BUNDLE) # Qt plugins directory in within the MacOS bundle set(MACPKG_QT_PLUGINS_DIR PlugIns) # conform to settings in `qt.conf` # convert CMake list to space-separated lists - string(REPLACE ";" " " MACPKG_EXTRA_LIBS "${BA_Dependencies}") - string(REPLACE ";" " " MACPKG_QT_PLUGINS "${MACPKG_QT_PLUGINS}") + set(MACPKG_EXTRA_LIBS "${BA_Dependencies}") + + configure_file("${CMAKE_SOURCE_DIR}/devtools/deploy/mac/mac_package.py.in" + "${BUILD_VAR_DIR}/mac_package.py" @ONLY) - configure_file("${CMAKE_SOURCE_DIR}/devtools/deploy/mac/adjust_mac_bundle.zsh.in" - "${BUILD_VAR_DIR}/adjust_mac_bundle.zsh" @ONLY) endif() if(WIN32) diff --git a/CHANGELOG b/CHANGELOG index 58b32a06e4c151bc77c830daa71872240dbee24e..18dd23b4828a9b3824a6c8b9a4c90f6b769e29d7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,7 +3,7 @@ BornAgain-22.0, in preparation * Scan axes have bins of width 0 * LambdaScan for time-of-flight offspec * Datafield::flat flattens out axis of size 1 -> API: + > API: * FlatDetector replaces RectangularDetector * SphericalDetector is obsolete. For 2D scattering, use FlatDetector as shown in examples * Offspec analyzer is set for detector, not scan(#651) @@ -26,6 +26,25 @@ BornAgain-22.0, in preparation * In scattering simulation, restrict Re(n) to [0.9,1.1], to prevent unintended computation with erroneous material data (e.g. accidental omission of factor 1e-6) (#715) +BornAgain-21.1, released 2023.10.19 + > Bug fixes: + * Repair backward compatibility of GUI projects + * Apply q-space units in 1D data loader + * Fix simulation background (#323) + * Untangle polarizer and analyzer checkboxes (#739) + * Fix applying wavelength distribution to specular scan in GUI (#740) + * Fix depthprobe simulation with averaged particle layer (#753) + * Fix linking 1D data to instrument + > GUI improvements: + * Show SLD units in the material editor + * Change the default minimizer and the objective metric + * Add new options to 1D data loader: + * Sort by argument + * Discard negative argument + * Discard argument duplications + * Alpha and lambda distributions are disabled as unsupported in depth-probe and + off-specular simulations + BornAgain-21.0, released 2023.08.16 > New functionality: * Averaged magnetization profile can be obtained with 'magnetizationProfile()' (#77) diff --git a/GUI/CMakeLists.txt b/GUI/CMakeLists.txt index f19949702d2883e3f0c02a2fb8d6d5354970b0b3..8066cf044ef68530d4ae154f3091348a4f65ea2d 100644 --- a/GUI/CMakeLists.txt +++ b/GUI/CMakeLists.txt @@ -40,6 +40,10 @@ endif() add_library(${lib} SHARED ${source_files} ${RC_SRCS} ${include_files} ${form_files}) #set_target_properties(${lib} PROPERTIES PREFIX ${libprefix} SUFFIX ${libsuffix}) +get_target_property(lib_dir ${lib} LIBRARY_OUTPUT_DIRECTORY) +set(BornAgain_LIBRARIES "$CACHE{BornAgain_LIBRARIES};${lib_dir}/${libprefix}${lib}${libsuffix}" + CACHE INTERNAL "BornAgain libraries") + # switch OFF Qt debug output in any configuration except DEBUG target_compile_definitions(${lib} PUBLIC $<$<NOT:$<CONFIG:Debug>>:QT_NO_DEBUG_OUTPUT>) diff --git a/cmake/BornAgain/MakeLib.cmake b/cmake/BornAgain/MakeLib.cmake index 0f9a0ef5b87cd18d09933931f6046773d748f830..5387f61af19d065216766a5648db3246ecf39b5d 100644 --- a/cmake/BornAgain/MakeLib.cmake +++ b/cmake/BornAgain/MakeLib.cmake @@ -35,6 +35,10 @@ function(MakeLib lib) # eg., libBornAgainBase.so _BASEFILENAME ${libprefix}${lib}${libsuffix}) + get_target_property(lib_dir ${lib} LIBRARY_OUTPUT_DIRECTORY) + get_target_property(lib_name ${lib} _BASEFILENAME) + set(BornAgain_LIBRARIES "$CACHE{BornAgain_LIBRARIES};${lib_dir}/${lib_name}" CACHE INTERNAL "BornAgain libraries") + # NOTE on run-time linking: # - Linux: # See <https://en.wikipedia.org/wiki/Rpath> diff --git a/cmake/configurables/FixPack.cmake.in b/cmake/configurables/FixPack.cmake.in index 6e19bbf0248f117f1848347f925a2fcb3e3f0d4f..cf376bf22d2fd62b41eba36f6966ac0051e8525a 100644 --- a/cmake/configurables/FixPack.cmake.in +++ b/cmake/configurables/FixPack.cmake.in @@ -33,7 +33,8 @@ if(LINUX) "@BUILD_VAR_DIR@/adjust_pkg_linux.sh" "${pkg_root}") elseif(APPLE) addPyWheel("${pkg_root}/@destination_pypackage@") - execute_process(COMMAND zsh "@BUILD_VAR_DIR@/adjust_mac_bundle.zsh" + message("FixPack: @Python3_EXECUTABLE@ '@BUILD_VAR_DIR@/mac_package.py' '${pkg_root}/@destination_root@'") + execute_process(COMMAND "@Python3_EXECUTABLE@" "@BUILD_VAR_DIR@/mac_package.py" "${pkg_root}/@destination_root@") elseif(WIN32) addPyWheel("${pkg_root}/@destination_pypackage@") diff --git a/cmake/multipython/MakePythonWheel.cmake b/cmake/multipython/MakePythonWheel.cmake index 5c3a399330e60bd02007c0157064e7aa42ceb62f..7806f152b8a01b03ca1be5aaa71be6d18154b04c 100644 --- a/cmake/multipython/MakePythonWheel.cmake +++ b/cmake/multipython/MakePythonWheel.cmake @@ -25,8 +25,12 @@ function(make_python_wheel) # On MacOS, building the Python packages needs further effort # which is performed in a dedicated shell script which # should be executed after build process is done (like CPack). - configure_file("${CMAKE_SOURCE_DIR}/devtools/deploy/mac/mk_pypack_macos.zsh.in" - ${BUILD_VAR_DIR}/mk_pypack_macos.zsh @ONLY) + set(BA_PY_MODE "ON") + set(MACPKG_EXTRA_LIBS "${_extra_libs}") + + configure_file("${CMAKE_SOURCE_DIR}/devtools/deploy/mac/mac_package.py.in" + ${BUILD_VAR_DIR}/mac_py_package.py @ONLY) + add_custom_target(BAPyWheel ALL COMMENT "${header} Script to build the Python wheel: " "'${${BUILD_VAR_DIR}/mk_pypack_macos.zsh}'" diff --git a/devtools/deploy/mac/adjust_mac_bundle.zsh.in b/devtools/deploy/mac/adjust_mac_bundle.zsh.in deleted file mode 100644 index c5ab991b1c9776353e031a4f5ae91e708f26225f..0000000000000000000000000000000000000000 --- a/devtools/deploy/mac/adjust_mac_bundle.zsh.in +++ /dev/null @@ -1,350 +0,0 @@ -#!/bin/zsh - -# ************************************************************************** # -# BornAgain: simulate and fit reflection and scattering -# -# @file adjust_mac_bundle.zsh.in -# @brief Adjusts the library references in the MacOS bundle to make -# them relocatable. -# -# @homepage http://apps.jcns.fz-juelich.de/BornAgain -# @license GNU General Public License v3 or higher (see COPYING) -# @copyright Forschungszentrum Juelich GmbH 2016 -# @authors Scientific Computing Group at MLZ (see CITATION, AUTHORS) -# ************************************************************************** # - -# Adjust MacOS Bundle: -# This script is called by CPack to adjust the bundle contents before making -# the final DMG file. -# See also: -# - 'Qt for macOS - Deployment' <https://doc.qt.io/qt-5/macos-deployment.html> -# - 'Using qt.conf' <https://doc.qt.io/qt-5/qt-conf.html> - -# Package structure: -# <Package-root> -# | -# +--MacOS {includes main executable: `bornagain`} -# | -# +--lib {main libraries, like `_libBornAgainBase.so`} -# | -# +--bin {not used} -# | -# +--Library {extra libraries, like `libformfactor`} -# | -# +--Frameworks {Qt framework} -# | -# +--PlugIns {Qt plugins) -# | -# +--Resources {icons and `qt.conf`} -# | -# +--share -# | -# +--BornAgain-<version> -# | -# +--Examples -# | -# +--Images - -# use extended glob (see <https://zsh.sourceforge.io/Doc/Release/Expansion.html>); -# similar to `shopt -s extglob` in Bash. -setopt KSH_GLOB -# allow empty or failing globs -setopt +o nomatch -setopt nullglob - -# include shell helper functions -# (expected to be in the same folder as the current script) -source "@CMAKE_SOURCE_DIR@/devtools/deploy/mac/shutils.zsh" -#======================================== -declare -r TITLE="* MacOS Package" - -#-- directories for the package binaries -pkg_root="$1" # root dir, eg. '/tmp/bornagain.app/Contents' -main_exe="$pkg_root/MacOS/@MACPKG_MAIN_EXE@" -extra_libs="@MACPKG_EXTRA_LIBS@" -# eg. input Qt dir = '/usr/local/opt/qt@5/lib/cmake/Qt' -# => '/usr/local/opt/qt@5' -qtdir="@Qt_DIR@" -# Qt plugins paths relative to Qt root dir -qt_plugins_rel_dir="@MACPKG_QT_PLUGINS_RELDIR@" -qt_framework_root="$qtdir" -# list of required Qt plugins -qt_plugins="@MACPKG_QT_PLUGINS@" -declare -ar qt_plugins=( ${=qt_plugins} ) - -if [[ -z "$pkg_root" ]] -then - echo "$TITLE: Error: Provide the root directory of the package." - exit 1 -fi - -if [[ -z "$qt_framework_root" ]] -then - echo "$TITLE: Error: Provide the root directory of the Qt framework." - exit 1 -fi - -if [[ ! -f "$main_exe" ]] -then - echo "$TITLE: Error: Main executable does not exist ($main_exe)." - exit 1 -fi - -echo "$TITLE: package root = '$pkg_root'" -echo "$TITLE: main executable = '$main_exe'" -echo "$TITLE: Qt framework root = '$qt_framework_root'" - -#-- directories (relative to root) which define the package structure -fwdir="Frameworks" -declare -A pkgbindir=( - "lib" "lib" # main library dir - "exlib" "Library" # external libraries dir - "FW" "$fwdir" # frameworks dir - "FW_qt" "$fwdir" # Qt framework dir - "FW_qt_plug" "@MACPKG_QT_PLUGINS_DIR@" # Qt plugins dir -) - -#-- copy extra libraries to the Framework-libraries dir; -# the name of the libraries must be the same as the library ids. -if [[ ! -z "$extra_libs" ]]; then - dst="$pkg_root/$pkgbindir[exlib]" - echo "$TITLE: Copy extra libraries to '$dst':" - mkdir -p "$dst" - for lib in ${=extra_libs}; do - # eg. 'usr/local/opt/libA.9.dylib' -> '<Package>/Framworks/lib/libA.9.dylib' - fnm=$(dylib_id "$lib") - cp -fL "$lib" "$dst/${fnm##*/}" - done - unset dst -fi - -#-- collect a list of binaries which are already placed in the package -declare -a libs_init=( - "$main_exe" - "$pkg_root/$pkgbindir[lib]"/*.(so|dylib) - "$pkg_root/$pkgbindir[exlib]"/*.(so|dylib) -) - -# add the required Qt plugins -echo "$TITLE: Copy required Qt plugins from '$qt_framework_root':" -pkg_plugins_dir="$pkg_root/$pkgbindir[FW_qt_plug]" -for plg in $qt_plugins; do - # full path of the plugin; - # eg. '/opt/qt@5/plugins/platforms/libqcocoa.dylib' - plgpth0="$qt_framework_root/$qt_plugins_rel_dir/$plg" - # copy the plugin to the same directory under the _package_ plugins dir; - # eg. '<Package>/PlugIns/platforms/libqcocoa.dylib' - pth="$pkg_plugins_dir/$plg" - mkdir -p "${pth%/*}" - cp -nL "$plgpth0" "$pth" - # add Qt plugin to the list of initial binaries - libs_init+=( "$pth" ) -done -declare -r libs_init - -echo "$TITLE: Initially installed binaries under '$pkg_root':" -for fnm in $libs_init; do - # eg., '+ lib/libA.dylib' - echo " + '${fnm#$pkg_root/}'" -done - -#-- find the dependencies of the binaries -declare -ar refs_all=( $(find_dependencies $libs_init) ) - -echo "$TITLE: All dependencies:" -# a sorted list of dependencies -for lib in ${(o)refs_all}; do - echo " + '$lib'" -done - -#-- distinguish absolute and relative references within dependencies -declare -a abs_refs rel_refs py_refs fw_refs -for ref in $refs_all; do - if [[ $ref = @* ]]; then - # relative reference; eg. '@rpath/foo.dylib' - rel_refs+=( "$ref" ) - elif [[ $ref = *[Pp]ython* ]] && [[ ! $ref = *boost* ]]; then - # Python dependencies must be considered separately (exclude 'libboost_python') - # eg. '/opt/py@3.9/Frameworks/Python.framework/Versions/3.9/Python' - py_refs+=( "$ref" ) - elif [[ $ref = *\.[Ff]ramework/* ]]; then - # frameworks must be considered separately - # eg. '/opt/qt@5/lib/QtGui.framework/Versions/5/QtGui' - fw_refs+=( "$ref" ) - else - # absolute reference; eg. '/usr/opt/libA.so' - abs_refs+=( "$ref" ) - fi -done - -#-- copy all absolute dependencies to the package -dst="$pkg_root/$pkgbindir[exlib]" -mkdir -p "$dst" -echo "$TITLE: Copy external libraries to '$dst':" -# pkglib: map{ library reference => destination full-path } -# e.g., /usr/local/opt/libA.dylib => /pkg-root/Library/libA.dylib -declare -A pkglib -for ref in $abs_refs; do - pth="$dst/${ref##*/}" # destination full-path - pkglib[$ref]="$pth" - cp -nL "$ref" "$pth" -done -unset dst - -#-- copy all framework dependencies to the package -# Qt framework -qt_fwdir="$pkg_root/$pkgbindir[FW_qt]" -echo "$TITLE: Copy Qt-framework libraries to '$qt_fwdir':" -# extract framework path: -# eg. '/usr/local/opt/qt@5/lib/QtWidgets.framework/Versions/5/QtWidgets (...)' -# => 'QtWidgets.framework/Versions/5/QtWidgets' -framework_re='s;.+/([^/]+\.[Ff]ramework/[^[:blank:]]+).*;\1;' -for ref in $fw_refs; do - # only Qt framework is considered - if [[ ${ref##*/} = Qt* ]]; then - # eg., copy '/opt/qt@5/lib/QtGui.framework/Versions/5/QtGui' - # to '<Frameworks>/Qt/QtGui.framework/Versions/5/QtGui' - qtfwdir0=$(echo $ref | sed -E $framework_re) - pth="$qt_fwdir/$qtfwdir0" - mkdir -p "${pth%/*}" - pkglib[$ref]="$pth" - cp -nL "$ref" "$pth" - else - echo "Framework '$ref' neglected." >&2 - fi -done - -# Add relatively-referenced Qt framework; -# e.g., QtDBus framework referenced as `@rpath/QtDBus.framework/Versions/A/QtDBus` -echo "$TITLE: Add relatively-referenced Qt framework libraries:" -qt_exlib_dir="$pkg_root/$pkgbindir[exlib]" -for ref in $rel_refs; do - # select only Qt relative references - if [[ ${ref##*/} = Qt* ]]; then - # '@rpath/QtDBus.framework/Versions/A/QtDBus' => 'QtDBus.framework/Versions/A/QtDBus' - ref_qt=${ref/@rpath\//} - qt_src="$qt_framework_root/lib/$ref_qt" - qt_dst="$qt_fwdir/$ref_qt" - qt_dst_dir="${qt_dst%/*}" - pkglib["REL-$qt_dst"]="$qt_dst" - mkdir -p "$qt_dst_dir" - cp -nL "$qt_src" "$qt_dst" - - # add the *1st-order optional* dependencies of the Qt library (if any) - # to the same folder as that of the Qt library - deps1=$(dylib_deps "$qt_dst" | sed -nE "s;[[:blank:]]*(/.+/opt/.+)[[:blank:]]\(.+;\1;p") - has_deps=0 - for libpth_ in ${=deps1}; do - has_deps=1 - # '/usr/local/opt/libA.1.2.dylib' => 'libA.1.2.dylib' - libname_="${libpth_##*/}" - # cp the library to the external library dir of the package - libdst="$qt_exlib_dir/$libname_" - pkglib[$libpth_]="$libdst" - cp -nL "$libpth_" "$libdst" - done - fi -done - -#-- collect all package binaries for later process -declare -ar pkgbins=( $(rm_list_duplicates ${(v)pkglib} $libs_init "$main_exe") ) - -echo "$TITLE: All binaries:" -# a sorted list of binaries -for lib in ${(o)pkgbins}; do - echo " + '${lib#$pkg_root/}'" -done - -#-- adjust references to libraries -echo "$TITLE: Adjust references to libraries:" -libdir="$pkg_root/$pkgbindir[lib]" -declare -A rpaths bin_deps -for bin in $pkgbins; do - declare -A rpaths_tmp=() - bin_deps[$bin]=$(dylib_deps "$bin") - bindir="${bin%/*}" # abs. dir of target binary - # abspth0 = original abs. full-path of the library - # abspth_pkg = abs. full-path of the library in the package - for abspth0 abspth_pkg in ${(kv)pkglib}; do - # if the binary does not depend on the current library, do nothing - [[ "$bin_deps[$bin]" != *"$abspth0"* ]] && continue - # change the library reference in the binary - # eg. '/usr/local/opt/lib/libA.5.dylib' => '@rpath/libA.5.dylib' - libname="${abspth_pkg##*/}" # library filename - libdir="${abspth_pkg%/*}" # abs. dir of the referenced library - librelpth="" # rel. path of the library - # make a framework-relative path for the Qt framework libraries - # eg. '/opt/qt@5/lib/QtGui.framework/Versions/5/QtGui' - # => 'QtGui.framework/Versions/5' - if [[ $libname == Qt* ]]; then - # rm framework root dir from the beginning; - # eg. '/opt/qt@5/lib/QtGui.framework/Versions/5/QtGui' - # => 'QtGui.framework/Versions/5/QtGui' - librelpth="${abspth_pkg#$qt_fwdir/}" - # rm filename from the end - # eg. 'QtGui.framework/Versions/5/QtGui' - # => 'QtGui.framework/Versions/5' - librelpth="${librelpth%/*}" - fi - ref_new="$libname" - # prepend with library rel. path, if any - [[ ! -z $librelpth ]] && ref_new="$librelpth/$ref_new" - install_name_tool "$bin" -change "$abspth0" "@rpath/$ref_new" 2> >(grep -v "invalidate the code signature") - # make a proper RPATH to refer to the library within the package - # eg. '@loader_path/../Frameworks/Qt/' - rpath="@loader_path/"$(find_rpath "$bindir" "$libdir" "$librelpth") - rpaths_tmp[$rpath]=1 - done - # store a duplicateless list of rpaths needed for the binary, - # only if some rpaths are set. - # NOTE: libraries under the package lib dir. often need - # an extra RPATHs '../Library'. - if [[ $bin == $libdir/* ]]; then - rpaths_tmp[@loader_path/../$pkgbindir[exlib]]=1 - fi - rpath_set="${(k)rpaths_tmp}" - if [[ ! -z "$rpath_set" ]] && rpaths[$bin]="$rpath_set" -done - -# find the Python dependence for the *main* libraries -libs_main=( $pkg_root/$pkgbindir[lib]/*.(dylib|so) ) -declare -a libs_pydep # list of Python-dependent libraries -for lib in $libs_main; do - # get the first element the list Python dependences (all others must be the same) - _pydep=$(get_python_dependence "$bin_deps[$lib]") - # record the list of Python-dependent libraries - [[ ! -z ${_pydep//[[:blank:]]/} ]] && libs_pydep+=( "$lib" ) -done -declare -r libs_pydep -# NOTE: zsh array indexing starts at 1 (unless option KSH_ARRAYS is set) -_pydep=( ${=_pydep} ) -pydepends_fullpath=$_pydep[1] -pydepends_filename=$_pydep[2] -unset _pydep -declare -ar py_fmwk_rpaths=( $(get_python_framework_path "$pydepends_fullpath") ) - -for lib in $libs_pydep; do - rpaths[$lib]+=" $py_fmwk_rpaths[@]" -done - -echo "$TITLE: Python dependence for the main libraries in '$pkg_root/$pkgbindir[lib]':" -echo " + path: '$pydepends_fullpath'" -echo " + library: '$pydepends_filename'" -echo " + framework paths:" -for pth in $py_fmwk_rpaths; do - echo " - '$pth'" -done - -echo "$TITLE: Add proper RPATHs to the binaries:" -for bin in $pkgbins; do - rpaths_bin="${=rpaths[$bin]}" - # trim extra blanks - rpaths_bin_tr=$(echo -e "${rpaths_bin}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - # eg. RPATHS for 'lib/libA.dylib': ../Library , ../Frameworks/Qt - if [[ ! -z $rpaths_bin_tr ]]; then - # eg. install_name_tool libA.so -add_rpath RPATH1 -add_rpath RPATH2 ... - eval install_name_tool $bin -add_rpath ${rpaths_bin_tr// / -add_rpath } 2> >(grep -v "invalidate the code signature") - fi -done - -echo "$TITLE: Done." diff --git a/devtools/deploy/mac/mac_package.py.in b/devtools/deploy/mac/mac_package.py.in new file mode 100644 index 0000000000000000000000000000000000000000..2e7c55a587e908f5d3cb86887c8848662df73a78 --- /dev/null +++ b/devtools/deploy/mac/mac_package.py.in @@ -0,0 +1,903 @@ +# ************************************************************************** # +# BornAgain: simulate and fit reflection and scattering +# +# @file mac_package.py.in +# @brief Adjusts the library references in the MacOS bundle to make +# them relocatable. +# +# @homepage http://apps.jcns.fz-juelich.de/BornAgain +# @license GNU General Public License v3 or higher (see COPYING) +# @copyright Forschungszentrum Juelich GmbH 2016 +# @authors Scientific Computing Group at MLZ (see CITATION, AUTHORS) +# ************************************************************************** # + +# Adjust MacOS Bundle: +# This script is called by CPack to adjust the bundle contents before making +# the final DMG file. The package root directory is provided as an argument when +# CPack runs the script (see `cmake/configurables/FixPack.cmake.in`). +# The script follows the rules explained in `man dyld (1)`. +# Furthermore, it uses MacOS native tools `otool` and `install_name_tool` to adjust the binaries. +# It finds the dependencies of the core libraries up to a maximum level. +# The dependencies are then copied to the corresponding package directories, +# and library references and RPATHs are modified accordingly. + +# Adjust Python wheel: +# In the 'Python mode', the script produces a Python wheel +# (see `cmake/multipython/MakePythonWheel.cmake`). + +# Requires MacOS-specific native tools `otool` and `install_name_tool`. +# NOTE: Starting with macOS Catalina (macOS 10), Macs will use `zsh` as +# the default login shell and interactive shell across the operating system. +# All newly created user accounts in macOS Catalina will use zsh by default; +# see <https://support.apple.com/en-us/HT208050> + +# See further: +# - Runtime linking on Mac <https://matthew-brett.github.io/docosx/mac_runtime_link.html> +# - Apple developer documentation: 'Run-Path Dependent Libraries' <https://apple.co/3HVbMWm> +# - Loading Dynamic Libraries on Mac <http://clarkkromenaker.com/post/library-dynamic-loading-mac> +# - <https://stackoverflow.com/q/66268814> +# - dlopen manpage +# - 'Qt for macOS - Deployment' <https://doc.qt.io/qt-6/macos-deployment.html> +# - 'Using qt.conf' <https://doc.qt.io/qt-6/qt-conf.html> + +# Package structure: +# <Package-root> +# | +# +--MacOS {includes main executable: `bornagain`} +# | +# +--lib {main libraries, like `_libBornAgainBase.so`} +# | +# +--bin {not used} +# | +# +--Library {extra libraries, like `libformfactor`} +# | +# +--Frameworks {Qt framework} +# | +# +--PlugIns {Qt plugins) +# | +# +--Resources {icons and `qt.conf`} +# | +# +--share +# | +# +--BornAgain-<version> +# | +# +--Examples +# | +# +--Images +#------------------------------------------------------------------------------80 + +import sys, os, shutil, glob, platform, re, subprocess as subp +from collections import namedtuple + +TITLE="* MacOS Package" +PKGROOT = '<PKG-ROOT>' + +def mkdirs(path:str): + if not os.path.exists(path): + os.makedirs(path) + + +def removeprefix(str_:str, pfx:str): + return str_.split(pfx, 1)[-1] + + +def splat(str_list:str, separator = ' '): + return [x for x in str_list.split(separator) if x] + + +def copyfile(src:str, dst:str, overwrite=False): + if not overwrite and os.path.exists(dst): return + shutil.copy(src, dst) + + +def dylib_id(libpath:str): + """ Returns the base library id for the given library """ + + return subp.check_output(["otool", "-XD", libpath], encoding='utf-8').strip() + + +def rm_list_duplicates(list0:list): + """ Removes duplicates from a given list """ + + return list(set(list0)) + + +def dylib_deps(libpath:str): + """ Uses 'otool' to get the 1st-order dependencies of a library (raw output). + Obtains the dependencies for a given library; + removes the name of the library itself from the references. + NOTE: Under MacOS, a Mach-O binary sometimes depends on itself. + + * otool output example: ' /usr/local/opt/foo.dylib (compatibility ...)' + """ + + deps = subp.check_output(["otool", "-XL", libpath], encoding='utf-8').split('\n') + return [d.strip() for d in deps if d] + + +def find_dylibs(abspath:str): + """ Returns the filenames corresponding to the pattern *.so or *.dylib """ + + return glob.glob(abspath + '/*.so') + glob.glob(abspath + '/*.dylib') + + +def get_depends1(libpath:str) -> list: + """ Obtains 1st-order dependencies for a given file. + Obtains the 'non-system' dependencies for a given library; + eg. ' /usr/local/opt/foo.1.dylib (compatibility ...)' => '/usr/local/opt/foo.dylib' + * system dependencies pattern: /System/, /Library/, /usr/lib/, /usr/local/lib + [See man dyld(1)] + """ + + # discard system dependencies + deps_ = [d for d in dylib_deps(libpath) + if not re.match(r'/(usr/lib|usr/local/lib|System|Library)/', d.strip())] + deps = list() + for d in deps_: + # extract library path + m_ = re.search(r'\s*([\w@/.-]+)\s+.*', d) + if m_: deps.append(m_.group(1)) + + return deps + + +def common_root(abspath1:str, abspath2:str): + """ Finds the common root of two given _absolute_ paths """ + + if abspath1 == abspath2: + # if paths are equal, the root is equal to either of them + return abspath1 + + # convert paths to arrays of directories: + # replace '/' with blank and make an array out of the result; + # eg. '/root/lib/opt' => [root, lib, opt] + dirs1 = [d for d in abspath1.split('/') if d] + idxMax1 = len(dirs1) + dirs2 = [d for d in abspath2.split('/') if d] + idxMax2 = len(dirs2) + common_root = "" + for idx in range(min(idxMax1, idxMax2)): + # extract the head (topmost) directory name from paths + # eg. 'root/lib/opt' => 'root' + head1 = dirs1[idx] + head2 = dirs2[idx] + + # if both paths have the same head, then add head to the common root; + # otherwise, the common root is already obtained + if head1 != head2: break + # add the common head to the root + common_root += '/' + head1 + + return common_root + + +def find_rpath(bin_abspath:str, lib_abspath:str, lib_relpath:str=""): + """ Finds the proper rpath for given binary pointing to a reference library + # usage: find_rpath(bin_abspath, lib_abspath, lib_relpath) + # example: + # bin_abspath='/root/usr/opt/bin' + # lib_abspath='/root/usr/Frameworks/Qux/lib' + # lib_relpath='Qux/lib' + # returns: `../../Frameworks` + """ + + # drop the final '/' chars from all paths + bin_abspath = os.path.dirname(bin_abspath) # target binary for which a rpath is obtained + lib_abspath = os.path.dirname(lib_abspath) # referenced library + lib_relpath = os.path.dirname(lib_relpath) # relative path to the referenced library + # find the common root path + root_path = common_root(bin_abspath, lib_abspath) + if not root_path: return lib_abspath + + # obtain the path from the binary to the root + # eg. '/root/local/opt' => 'local/opt' => '../..' + _binpth_to_root = re.sub(root_path + '(/|$)', '', bin_abspath) + binpth_to_root = re.sub('[^/]+', '..', _binpth_to_root) + + # obtain the path from root to the referenced library; + # eg. '/root/local/opt' => 'local/opt' + # then, drop the relative path of the library from the end + _libpth_from_root = re.sub(root_path + '(/|$)', '', lib_abspath) + libpth_from_root = re.sub('(^|/)' + lib_relpath + '$', '', _libpth_from_root) + + # return the proper relative RPATH to the referenced library + # eg. '../../Frameworks/Qt' + libpth_from_bin = libpth_from_root if not binpth_to_root \ + else binpth_to_root + '/' + libpth_from_root + # return eg. '@loader_path/../../Frameworks' + return "@loader_path/" + libpth_from_bin + + +class PythonFramework: + """ Python framework details """ + + # regexp to extract the Python dependence; + # eg., '/Frameworks/Python.framework/Versions/3.9/Python' + libdir_re = r'[\w./@.-]+' + pylibname_re = r'Python|libpython.+\.dylib' + py_fmwk_re = re.compile('(' + libdir_re + ')/(' + pylibname_re + ')') + + # regexp to extract the Python framework path + # eg. '/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/Python' + # => '/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/' + py_fmwk_path_re = r'(.*)/(Python|libpython).*' + # possible framework paths on MacOS + framework_paths = ("/usr/local/Library/Frameworks", + "/Library/Frameworks", "/usr/local/Frameworks", + "/opt/homebrew/Frameworks") + + @staticmethod + def dependence(dependencies:list) -> (str, str): + """ Extracts the Python dependency from the given dependencies + # NOTE: Under OSX, Python's library name is either 'Python', or + # for Python3.9, 'libpython3.9.dylib'. + """ + + # regexp to correct the Python dependence; eg.: + # '/Users/usr1/.pyenv/versions/3.9.18/lib/libpython3.9.dylib' => 'libpython3.9.dylib' + # '/Frameworks/Python.framework/Versions/3.9/Python' => 'libpython3.9.dylib' + # '/Frameworks/Python.framework/Versions/3.9/libpython3.9.dylib' => 'libpython3.9.dylib' + # obtain the dependencies + pydepends_path_list = [d for d in dependencies if PythonFramework.py_fmwk_re.match(d)] + pydepends_path = pydepends_filename = None + if pydepends_path_list: + pydepends_filename = pydepends_path_list[0] + mtch = PythonFramework.py_fmwk_re.match(pydepends_filename) + pydepends_path = mtch[1] + pydepends_filename = mtch[2] + # return the Python dependence fullpath and filename + return pydepends_path, pydepends_filename + + @staticmethod + def framework_path(pydepends_path:str) -> (str, str): + """ Produces proper Python framework paths for a given Python dependency """ + + # extract the Python framework path + py_fmwk_dir = re.match(PythonFramework.py_fmwk_path_re, pydepends_path, re.IGNORECASE)[1] + + # when the library is like '.../Versions/3.9/Python', then + # add an extra '/lib' to the framework path; + # this is needed since it refers to the standard location of the + # Python shared library on OSX, '.../Versions/3.9/lib/libpython39.dylib'. + if pydepends_path.endswith('Python'): + py_fmwk_dir += '/lib' + + # regexp to extract the Python version; eg. '3.9' + pyversion = None + pyversion_m = re.match(r'.+versions/([0-9.]+).*', pydepends_path, re.IGNORECASE) + if pyversion_m: + pyversion = pyversion_m[1] + else: + pyversion_m = re.match(r'.+/libpython(.+)\.dylib', pydepends_path) + if pyversion_m: + pyversion = pyversion_m[1] + # '3.9.18' => '3.9' + pyversion = re.match(r'(\d+.\d+).*', pyversion)[1] + if not pyversion: + raise ValueError("Cannot extract Python version from path '%s'" + % pydepends_path) + # RPATHs corresponding to the common OSX framework paths + py_fmwk_basepath = "Python.framework/Versions/" + pyversion + "/lib" + # collect proper RPATHs for Python framework + py_fmwk_rpaths = [py_fmwk_dir] + for pth in PythonFramework.framework_paths: + py_fmwk_rpaths.append(pth + '/' + py_fmwk_basepath) + # return a list of possible framework paths + return py_fmwk_rpaths, pyversion + + @staticmethod + def make_wheel(python_exe, py_output_dir, wheel_dir): + # make the Python wheel + subp.check_output([python_exe, '-m', 'pip', 'wheel', py_output_dir, + '--no-deps', '--wheel', wheel_dir]) + +class dylibRPATHs: + """ Obtains the RPATHs of a library """ + + # extract the LC_RPATH sections from the output of `otool -l` command + LC_RPATH_rx = re.compile(r'.+RPATH.*\n.+\n.+\n') + # extract 'rpath' from a LC_RPATH section + rpath_rx = re.compile(r'path\s+([\w@/.-]+)\s+.*') + # aux. dictionary to find the source file from a library reference + libId_src_tbl = dict() + + @staticmethod + def find(libpath:str) -> tuple: + """ Returns the RPATHs for the given library """ + otool_output = subp.check_output(["otool", "-l", libpath], encoding='utf-8') + # 'raw' rpaths which might include '@loader_path' + _rpaths = dylibRPATHs.rpath_rx.findall( + '\n'.join(dylibRPATHs.LC_RPATH_rx.findall(otool_output))) + return tuple(r.rstrip('/') for r in _rpaths) + + @staticmethod + def resolve(rpathref:str, rpaths:list): + """ resolve a given rpath-reference to a list of possible paths + according to a given list of RPATHs """ + return tuple(rpathref.replace('@rpath', p) for p in rpaths) if rpaths else tuple() + + @staticmethod + def resolve_by_table(ref:str): + """ resolve a given rpath-reference to a table of reference => path """ + return dylibRPATHs.libId_src_tbl.get(ref, '') + + +class LibraryFile: + """ Library binary file """ + + def __init__(self, dtype:str='', src:str='', ref:str='', rpaths:list=None, + dst:str='', ID:str='', dependencies:set=None, parent=None): + self.dtype = dtype # dependency type + self.src = src # path to the library file + self.ref = ref # reference to the library + self.rpaths = rpaths if rpaths else set() # rpaths of the library + self.dst = dst # destination of the library in the package + self.ID = ID # library ID + self.ID_new = '' # new library ID + # list of dependencies of the library + self.dependencies = dependencies if dependencies else set() + self.parent = parent + + def follow(self): + """ Determines if the library dependencies should be followed """ + + # do not follow Python dependencies + ref_basename = os.path.basename(self.ref).lower() + if 'python' in ref_basename and 'boost' not in ref_basename: + return False + # follow only libraries for which there is an absolute source path + return self.src.startswith('/') + + @staticmethod + def find_source(lib, parent_lib): + """ Finds the absolute path to the source, as far as possible """ + + if lib.ref.startswith('@rpath') and parent_lib: + if parent_lib.rpaths: + parent_lib_dir = os.path.dirname(parent_lib.src) + rpaths_fixed = tuple(os.path.realpath(r.replace('@loader_path', parent_lib_dir)) + for r in parent_lib.rpaths) + # TODO: store fixed RPATHs + lib.src = next( + filter(os.path.isfile, dylibRPATHs.resolve(lib.ref, rpaths_fixed)), '') + elif lib.ref.startswith('@loader_path'): + # relative reference; eg. '@rpath/foo.dylib' + lib.dtype = 'rel@loader_path' + lib.src = (os.path.dirname(parent_lib.src) + '/' + + removeprefix(lib.ref, '@loader_path')) + elif lib.ref.startswith('/'): + # absolute path + lib.src = lib.ref + + # last attempt + if not lib.src: lib.src = dylibRPATHs.resolve_by_table(lib.ref) + # find the realpath of the source + if lib.src: lib.src = os.path.realpath(lib.src) + return lib.src + + @staticmethod + def dependency_type(lib): + """ Finds the dependency type """ + + if lib.dtype: return lib.dtype + dep = lib.ref + dep_basename_l = os.path.basename(dep).lower() + if 'python' in dep_basename_l and 'boost' not in dep_basename_l: + # Python dependencies must be considered separately (exclude 'libboost_python') + # eg. '/opt/py@3.9/Frameworks/Python.framework/Versions/3.9/Python' + lib.dtype = 'framework_py' + elif '.framework/' in dep.lower() and os.path.basename(dep).startswith('Qt'): + lib.dtype = 'framework_qt' + elif dep.startswith('@loader_path'): + # relative reference; eg. '@rpath/foo.dylib' + lib.dtype = 'rel@loader_path' + elif dep.startswith('@rpath'): + # relative reference; eg. '@rpath/foo.dylib' + lib.dtype = 'rel@rpath' + elif '.framework/' in dep.lower(): + # Frameworks must be considered separately + # eg. '/opt/qt@6/lib/QtGui.framework/Versions/6/QtGui' + lib.dtype = 'framework' + elif dep.startswith('/'): + # absolute reference; eg. '/usr/opt/libA.so' + lib.dtype = 'abs' + else: + lib.dtype = 'unknown' + + return lib.dtype + + def __str__(self): + return ("LibraryFile: dtype=%s, src=%s, ref=%s," + "rpaths=%s, dst=%s, ID=%s, dependencies=%s, parent-ref=%s" + % (self.dtype, self.src, self.ref, self.rpaths, self.dst, self.ID, + self.dependencies, self.parent.ref if self.parent else None)) + + def __repr__(self): + return self.__str__() + + +class Settings: + """ MacOS package configuration """ + + # TODO: make intermediate variables internal + + # Python-related variables + PyInfo = namedtuple("PythonInfo", ["dir", "lib", "version", "rpaths"]) + + def __init__(self, pkg_root, + main_exe_src, core_libs, extra_libs, + python_mode, pkg_py_root_dir, pkg_py_lib_dir, pkg_py_extra_lib_dir, + qt_framework_root, qt_framework_plugins_dir, qt_plugins_dir, qt_plugins): + + # map {reference/abs. path => LibraryFile} + self.libs = dict() + # package-related directories + fwdir = "Frameworks" + self.dirs = { + #-- absolute paths + "root": pkg_root, # package root dir + "qt_root": qt_framework_root, + "qt_root_plugins_dir": qt_framework_plugins_dir, + #-- relative paths + "exe": "MacOS", # main executable dir + "corelib": "lib", # core library dir + "exlib": "Library", # external libraries dir + "FW": fwdir, # frameworks dir + "FW_qt": fwdir + '/Qt', # Qt framework dir + "FW_qt_plugin": qt_plugins_dir, # Qt plugins dir + } + + # Python-package mode + self.python_mode = bool(python_mode) + if self.python_mode: + self.dirs['root'] = pkg_py_root_dir + self.dirs['corelib'] = removeprefix(pkg_py_lib_dir, pkg_py_root_dir + '/') + core_libs.extend(find_dylibs(pkg_py_lib_dir)) + self.dirs['exlib'] = removeprefix(pkg_py_extra_lib_dir, pkg_py_root_dir + '/') + extra_libs.extend(find_dylibs(pkg_py_extra_lib_dir)) + else: + # main executable + _main_exe_dst = self.dirs['root'] + '/' + self.dirs['exe'] \ + + '/' + os.path.basename(main_exe_src) + self.libs = {main_exe_src: + LibraryFile(dtype='main_exe', src=main_exe_src, dst=_main_exe_dst)} + #-- Qt plugins + self.libs.update({self.dirs['qt_root_plugins_dir'] + '/' + l: + LibraryFile(dtype='plugin_qt', src=l) for l in qt_plugins}) + + # core libraries + self.libs.update({l: LibraryFile(dtype='core_lib', src=l) for l in set(core_libs)}) + # extra libraries + self.libs.update({l: LibraryFile(dtype='extra_lib', src=l) for l in set(extra_libs)}) + # all initial libraries + self.libs_init = tuple(self.libs.keys()) + # map {source-path => destination-path} + self.src_dst_tbl = dict() + # map {source-path => destination-path} + self.copy_tbl = dict() + # map {destination-path => LibraryFile} + self.dst_tbl = dict() + # map {reference => destination-path} + self.ref_dst_tbl = dict() + # map {reference_initial => reference_new} + self.ref_tbl = dict() + # Python-related info + self.py:Settings.PyInfo = None + + @staticmethod + def _dependencies_at_level(libs_cur:dict, pkg_libs:dict) -> dict: + """ Obtains all dependencies at the current level """ + + deps_lv:dict[(str, LibraryFile)] = dict() # map {ref => parent-LibraryFile} + for lib_ref, parent_lib in libs_cur.items(): + # if not at the root level (i.e. library has parent) and + # library has been already discovered, then + # neglect the library to avoid infinite loops + if parent_lib and lib_ref in pkg_libs: continue + + lib = pkg_libs.setdefault(lib_ref, LibraryFile()) + lib.ref = lib_ref + lib.parent = parent_lib + # determine the type of the library and its source path + if not lib.src: LibraryFile.find_source(lib, parent_lib) + LibraryFile.dependency_type(lib) + # consider only absolute paths for the next level + if not lib.follow(): continue + # verify the existence of the file + if not os.path.isfile(lib.src): + raise FileNotFoundError("%s: dependency does not exist: '%s'" + % (TITLE, lib.src)) + # find the 1st-level dependencies of the library + lib.dependencies = get_depends1(lib.src) + # find RPATH references of the library + lib.rpaths = set(dylibRPATHs.find(lib.src)) + # find library ID; eg., '@rpath/local/libA.dylib' + lib.ID = dylib_id(lib.src) + # add the library ID to the ID-table + dylibRPATHs.libId_src_tbl[lib.ID] = lib.src + # add the library and its parent for the next iteration + deps_lv.update({d: lib for d in lib.dependencies}) + + return deps_lv + + @staticmethod + def dependencies(pkg_libs:dict, pkg_dirs:dict, libs_init:list, level_max:int): + """ Gathers all dependencies for the given initial libraries + up to a maximum (but arbitrary) level. + """ + + print("%s: Find dependencies (up to level %s)..." % (TITLE, level_max)) + # root level libraries/binaries + for ref, pkglib in pkg_libs.items(): + pkglib.parent = None + pkglib.src = ref + + # map {library-id => library source path} + dylibRPATHs.libId_src_tbl = {dylib_id(l): l for l in libs_init} + # find dependencies of libraries recursively, + # beginning from the initial libraries + level = 0 # current level nr. + libs_lv = {l: None for l in pkg_libs.keys()} # map {library ref => parent} + while libs_lv: + level += 1 + # avoid going infinitely deep (eg. due to some mistake) + if level > level_max: + print("%s: Dependency level %i exceeds the maximum allowed depth (%i)." + % (TITLE, level, level_max)) + break + # obtain all dependencies at the current level + print("==>[L%i]" % level) + for ref, parent_lib in libs_lv.items(): + print("[L%i] %s <= %s" + % (level, ref.replace(pkg_dirs['root'], PKGROOT), + parent_lib.src.replace(pkg_dirs['root'], PKGROOT) + if parent_lib and parent_lib.src else 'None')) + # parents for the next iteration + libs_lv = Settings._dependencies_at_level(libs_lv, pkg_libs) + + # TODO: + # if no source file is found for a ref., make another attempt + # to find the source via the reference table + # for lib in (l for l in libs_lv.values() if l and not l.src): + # lib.src = dylibRPATHs.resolve_by_table(lib.ref) + print("==>END.") + return pkg_libs + + @staticmethod + def destinations(pkg_libs, pkg_dirs, libs_init) -> (dict, dict): + """ Determines destinations for the binaries in the package """ + + # regex to obtain Qt framework subdirectory + # eg. "/usr/local/Cellar/qt/6.5.1_3/lib/QtWidgets.framework/Versions/A/QtWidgets" + # => ['QtWidgets.framework', '/Versions/A/QtWidgets'] + qt_fw_rpath_rx = re.compile(r'.+/(Qt\w+.framework)/(.+)', re.IGNORECASE) + # Qt framework destination + pkg_qt_framework_dir = pkg_dirs['root'] + '/' + pkg_dirs['FW_qt'] + # Qt plugins destination + pkg_qt_plugins_dir = os.path.realpath(pkg_dirs['root'] + '/' + pkg_dirs['FW_qt_plugin']) + + # extra libraries destination + # eg. 'usr/local/opt/libA.9.dylib' => '<Package>/Library/libA.9.dylib' + # the name of the libraries must be the same as the library ids + pkg_exlib_dir = os.path.realpath(pkg_dirs['root'] + '/' + pkg_dirs['exlib']) + + # core libraries destination + # eg. 'buildir/lib/CoreLib.so' => '<Package>/lib/CoreLib.so' + # the name of the libraries must be the same as the library ids + pkg_corelib_dir = os.path.realpath(pkg_dirs['root'] + '/' + pkg_dirs['corelib']) + # map {source-path => destination-path} + pkg_copy_tbl = {p.src: p.dst for p in pkg_libs.values() if p.dst} + # map {destination-path => LibraryFile} + pkg_dst_tbl = dict() + + for ref, lib in pkg_libs.items(): + # neglect libraries which have no source path or + # those which have already a destination + if not lib.src: continue + if lib.dst: + pkg_dst_tbl[lib.dst] = lib + continue + + basename_new = os.path.basename(lib.ID) + + # make new library ids: + # eg. '/usr/opt/lib/libtiff.6.dylib' => '@rpath/libtiff.6.dylib' + # '@loader_path/../lib/libicudata.73.dylib' => '@rpath/libicudata.73.dylib' + if basename_new: lib.ID_new = "@rpath/" + basename_new + # Qt framework libraries + if lib.dtype == 'framework_qt': + _mg = qt_fw_rpath_rx.match(lib.src).groups() + # eg. destination = <PKG-ROOT>/Frameworks/qt/QtWidgets.framework/Versions/A/QtWidgets" + _libID = _mg[0] + '/' + basename_new + + # eg. '/opt/qt/QtCore.framework/Versions/A/QtCore' => + # '@rpath/QtCore.framework/Versions/A/QtCore' + lib.ID_new = "@rpath/" + _libID + lib.dst = pkg_qt_framework_dir + '/' + _libID + elif lib.dtype == 'plugin_qt': + # full source path of the plugin; eg. '/opt/qt@5/plugins/platforms/libqcocoa.dylib'. + # full destination path of the plugin (same directory under the _package_ plugins dir); + # eg. '<Package>/PlugIns/platforms/libqcocoa.dylib' + lib.dst = pkg_qt_plugins_dir + '/' \ + + removeprefix(lib.src, pkg_dirs['qt_root_plugins_dir'] + '/') + elif lib.dtype == 'core_lib': + lib.dst = pkg_corelib_dir + '/' + basename_new + elif lib.dtype == 'extra_lib': + lib.dst = pkg_exlib_dir + '/' + basename_new + # dependencies with absolute path + elif lib.dtype == 'abs': + lib.dst = pkg_exlib_dir + '/' + basename_new + # dependencies with relative '@loader_path' reference for which a source path is found + elif lib.dtype == 'rel@loader_path': + if lib.src in pkg_copy_tbl: + lib.dst = pkg_copy_tbl[lib.src] + else: + lib.dst = pkg_exlib_dir + '/' + basename_new + # dependencies with '@rpath' reference for which a source path is found + elif lib.dtype == 'rel@rpath': + if lib.src in pkg_copy_tbl: + lib.dst = pkg_copy_tbl[lib.src] + else: + lib.dst = pkg_exlib_dir + '/' + basename_new + + if not lib.dst: continue + # verify that when the source is the same, the destinations is the same, regardless of the ref. + if lib.src in pkg_copy_tbl and lib.dst != pkg_copy_tbl[lib.src]: + raise AssertionError("Multiple destinations found for a single source file" + "'%s': %s != %s" % (lib.src, lib.dst, pkg_copy_tbl[lib.src])) + + pkg_copy_tbl[lib.src] = lib.dst + pkg_dst_tbl[lib.dst] = lib + + return pkg_copy_tbl, pkg_dst_tbl + + @staticmethod + def reference_destinations(pkg_libs:dict) -> dict: + """ Determines destinations for the references """ + + # map {basename => destination-path} + pkg_basename_dst_tbl = {os.path.basename(p.dst): p.dst + for p in pkg_libs.values() if p.dst} + # map {reference => destination-path} + pkg_ref_dst_tbl = dict() + for lib in pkg_libs.values(): + for dep in lib.dependencies: + # attempt to obtain the destination from package library + dep_lib = pkg_libs.get(dep) + # ignore Python framework files + if dep_lib and dep_lib.dtype == "framework_py": continue + _dst = dep_lib.dst if dep_lib else None + # otherwise, attempt to find the destination upon the basename + pkg_ref_dst_tbl[dep] = _dst \ + or pkg_basename_dst_tbl.get(os.path.basename(dep)) or '' + + return pkg_ref_dst_tbl + + @staticmethod + def new_references_rpaths(pkg_libs:dict, pkg_dst_tbl:dict, pkg_ref_dst_tbl:dict) -> (dict, tuple): + """ Determines proper RPATHs for each library """ + + pkg_ref_tbl = dict() # map {reference_initial => reference_new} + pkg_dst_lib = {l.dst: l for l in pkg_dst_tbl.values()} # map {destination path => LibraryFile} + + #-- determine proper Python RPATHs + # find all Python framework binaries + py_dir = py_lib = py_version = py_rpaths = None + py_framework_files = {l.ref: l for l in pkg_libs.values() + if l.dtype == "framework_py"} + + py_dir, py_lib = PythonFramework.dependence(py_framework_files.keys()) + py_rpaths, py_version = PythonFramework.framework_path(py_dir + '/' + py_lib) + for ref in py_framework_files.keys(): + pkg_ref_tbl[ref] = "@rpath/" + py_lib + + #-- determine all proper RPATHs for package binaries + for ref, lib in ((r, l) for r, l in pkg_libs.items() if l.dst): + rpaths_ini = set(lib.rpaths) # initial RPATHs + rpaths_new = set() # new RPATHs + # record references conversions + if lib.ID_new: pkg_ref_tbl[ref] = lib.ID_new + for dep in lib.dependencies: + # add Python-related RPATHs + if re.match(PythonFramework.py_fmwk_path_re, dep): + rpaths_new.update(py_rpaths) + # add RPATHs related to other libraries within the package + _dep_dst = pkg_ref_dst_tbl.get(dep) + if not _dep_dst: continue + # find the proper RPATH + _rel_path = pkg_dst_lib[_dep_dst].ID_new.replace("@rpath/", "") + _rpth = find_rpath(lib.dst, _dep_dst, _rel_path) + rpaths_new.add(_rpth.rstrip('/')) + + # new RPATHs should be different from the initial ones + lib.rpaths = rpaths_new.difference(rpaths_ini) + + return pkg_ref_tbl, (py_dir, py_lib, py_version, py_rpaths) + +#------------------------------------------------------------------------------80 + +_core_libs = "@MACPKG_CORE_LIBS@" # core libraries and executables +core_libs = splat(_core_libs, ';') +_extra_libs = "@MACPKG_EXTRA_LIBS@" +extra_libs = splat(_extra_libs, ';') + +main_exe_src = "@MACPKG_MAIN_EXE@" +pkg_root = "" +if len(sys.argv) > 1: + # root dir, eg. '/tmp/bornagain.app/Contents' + pkg_root = os.path.dirname(sys.argv[1] + '/') + +# Python-related variables +python_mode = bool("@BA_PY_MODE@") +python_exe = "@__Py3_EXECUTABLE@" +pkg_py_output_dir = os.path.dirname("@BA_PY_OUTPUT_DIR@/") +pkg_py_root_dir = os.path.dirname("@BA_PY_INIT_OUTPUT_DIR@/") +pkg_py_lib_dir = os.path.dirname("@BA_PY_LIBRARY_OUTPUT_DIR@/") +pkg_py_extra_lib_dir = os.path.dirname("@BA_PY_EXTRA_LIBRARY_OUTPUT_DIR@/") +pkg_py_wheel_dir = os.path.dirname("@BA_PY_PACKAGE_WHEEL_DIR@/") + +# Qt-related variables +# eg. input Qt dir = '/usr/local/opt/qt@5/lib/cmake/Qt' +# => '/usr/local/opt/qt@5' +qtdir = "@Qt_DIR@/" +# Qt plugins paths relative to Qt root dir +qt_plugins_rel_dir = "@MACPKG_QT_PLUGINS_RELDIR@/" +qt_framework_root = os.path.dirname(qtdir) +qt_framework_plugins_dir = os.path.dirname(qt_framework_root + '/' + qt_plugins_rel_dir) + +# list of required Qt plugins +_qt_plugins = "@MACPKG_QT_PLUGINS@" +qt_plugins = splat(_qt_plugins, ';') +qt_plugins_dir = "@MACPKG_QT_PLUGINS_DIR@/" + +if python_mode: + if not python_exe: + raise ValueError("%s: Error: Provide the Python executable." % TITLE) + + if not pkg_py_root_dir: + raise ValueError("%s: Error: Provide the root directory of the Python package." % TITLE) + + if not extra_libs: + core_libs = find_dylibs(pkg_py_lib_dir) + + pkg_root = pkg_py_root_dir +else: + if not pkg_root: + raise ValueError("%s: Error: Provide the root directory of the package." % TITLE) + + if not main_exe_src: + raise ValueError("%s: Error: Main executable does not exist." % TITLE) + +#------------------------------------------------------------------------------80 + +print("-- Package variables --") +print("%s: package root = '%s'" % (TITLE, pkg_root)) +print("%s: main executable = '%s'" % (TITLE, main_exe_src)) +print("%s: core libraries = '%s'" % (TITLE, core_libs)) +print("%s: extra libraries = '%s'" % (TITLE, extra_libs)) +print("-- Python") +print("%s: Python mode = %s" % (TITLE, python_mode)) +print("%s: Python executable = '%s'" % (TITLE, python_exe)) +print("%s: Python package root dir = '%s'" % (TITLE, pkg_py_root_dir)) +print("%s: Python package libraries dir = '%s'" % (TITLE, pkg_py_lib_dir)) +print("%s: Python package extra libraries dir = '%s'" % (TITLE, pkg_py_extra_lib_dir)) +print("%s: Python wheel dir = '%s'" % (TITLE, pkg_py_wheel_dir)) +print("-- Qt") +print("%s: Qt framework root = '%s'" % (TITLE, qt_framework_root)) +print("%s: Qt plugings dir = '%s'" % (TITLE, qt_plugins_dir)) +print("%s: Qt plugings = '%s'" % (TITLE, qt_plugins)) +print("-----------------------") + +#-- package definition +pkg = Settings(pkg_root, + main_exe_src, core_libs, extra_libs, + python_mode, pkg_py_root_dir, pkg_py_lib_dir, pkg_py_extra_lib_dir, + qt_framework_root, qt_framework_plugins_dir, qt_plugins_dir, qt_plugins) + +#-- print package information +print("\n%s: package directories:" % TITLE) +for lbl, val in pkg.dirs.items(): + print(" %s: %s" % (lbl, val)) + +print("\n%s: Initial binaries:" % TITLE) +for fnm in pkg.libs_init: + # eg., '+ lib/libA.dylib' + print(" + '%s'" % removeprefix(fnm, pkg.dirs['root'] + '/')) + +print() + +#-- find the dependencies of the binaries +pkg.libs = Settings.dependencies(pkg.libs, pkg.dirs, pkg.libs_init, level_max=10) + +#-- determine the destination of libraries in the package +# map {source-path => destination-path} +pkg.src_dst_tbl, pkg.dst_tbl = Settings.destinations(pkg.libs, pkg.dirs, pkg.libs_init) + +#-- print all discovered dependencies +print("\n%s: All dependencies (type, source, reference):" % TITLE) +for lib in sorted(pkg.libs.values(), key=lambda l: (l.dtype, l.src)): + print(" + [%s]: %s {%s}" % + (lib.dtype, + lib.src.replace(pkg_root, PKGROOT), + lib.ref.replace(pkg_root, PKGROOT))) + +print("\n%s: Installation table (source, destination):" % TITLE) +for src in sorted(pkg.src_dst_tbl.keys()): + dst = pkg.src_dst_tbl.get(src) + print(" + %s => %s" % + (src, dst.replace(pkg_root, PKGROOT) if dst else 'None')) + +#-- determine proper RPATHs for each library +print("\n%s: Building reference-destination table..." % TITLE) +pkg.ref_dst_tbl = Settings.reference_destinations(pkg.libs) +for ref, dst in pkg.ref_dst_tbl.items(): + if not dst: + raise AssertionError("%s: reference '%s' is not resolved" % (TITLE, ref)) + print(" + {%s} => %s" % (ref, dst.replace(pkg_root, PKGROOT))) + +pkg.ref_tbl, py_info = Settings.new_references_rpaths(pkg.libs, pkg.dst_tbl, pkg.ref_dst_tbl) +pkg.py = Settings.PyInfo(*py_info) + +print("\n%s: Python dependence of package libraries:" % TITLE) +if pkg.py.lib: + print(" + path: '%s'" % pkg.py.dir) + print(" + library: '%s'" % pkg.py.lib) + print(" + version: '%s'" % pkg.py.version) + print(" + framework paths:") + for pth in pkg.py.rpaths: + print(" - %s" % pth) +else: + print(" No Python dependence found.") + +print("\n%s: Reference conversion table (ref_old, ref_new):" % TITLE) +for ref_old, ref_new in pkg.ref_tbl.items(): + print(" + %s => %s" % (ref_old, ref_new)) + +print("\n%s: Library RPATHs (source, RPATHs):" % TITLE) +for lib in (l for l in pkg.dst_tbl.values() if l.rpaths): + print(" + %s: %s" % (lib.src.replace(pkg_root, PKGROOT), + lib.rpaths)) + +#-- copy binaries to their package destinations +print("\n%s: Install package files..." % TITLE) +for dst, lib in pkg.dst_tbl.items(): + mkdirs(os.path.dirname(dst)) + copyfile(lib.src, dst) + +#-- apply RPATHs +print("\n%s: Add proper RPATHs to the binaries..." % TITLE) +for lib in pkg.dst_tbl.values(): + # eg. RPATHS for 'lib/libA.dylib': ../Library , ../Frameworks/Qt + # eg. install_name_tool libA.so -add_rpath RPATH1 -add_rpath RPATH2 ... + _cmd_list = [] + for cmd in (["-add_rpath", r] for r in lib.rpaths): + _cmd_list.extend(cmd) + # execute the command + if not _cmd_list: continue + _prc = subp.run(["install_name_tool", lib.dst, *_cmd_list]) + if _prc.returncode != 0: + print("%s: Command failed: %s" % (TITLE, _prc.args)) + +# modify the library references; +# eg. '/usr/local/opt/foo.dylib' => '@rpath/foo.dylib' +print("\n%s: Change library references in binaries..." % TITLE) +for lib in pkg.dst_tbl.values(): + # eg. install_name_tool libA.so -change REF1_old REF1_new -change REF2_old REF2_new ... + _cmd_list = [] + for ref_old in lib.dependencies: + ref_new = pkg.ref_tbl[ref_old] + if ref_old != ref_new: _cmd_list.extend(["-change", ref_old,ref_new]) + # execute the command + if not _cmd_list: continue + _prc = subp.run(["install_name_tool", lib.dst, *_cmd_list]) + if _prc.returncode != 0: + print("%s: Command failed: %s" % (TITLE, _prc.args)) + +#-- make Python wheel (when in Python mode) +if pkg.python_mode: + print("%s: creating Python wheel in '%s'..." % (TITLE, pkg_py_wheel_dir)) + print(" Python executable = '%s'" % python_exe) + PythonFramework.make_wheel(python_exe, pkg_py_output_dir, pkg_py_wheel_dir) + +#-- END +print("\n%s: Done.\n" % TITLE) diff --git a/devtools/deploy/mac/mk_pypack_macos.zsh.in b/devtools/deploy/mac/mk_pypack_macos.zsh.in index 7eb01138503bbb1e29fd230b53d83de3f227fc8f..990aa24efd421220e8a5a075e8fa9e1aa7cf4273 100644 --- a/devtools/deploy/mac/mk_pypack_macos.zsh.in +++ b/devtools/deploy/mac/mk_pypack_macos.zsh.in @@ -56,6 +56,11 @@ # To obtain the value of the config variable use: # % brew config | sed -nE 's;.*HOMEBREW_PREFIX:[[:blank:]]*(.+)[[:blank:]]*;\1;p' +# include shell helper functions +# (expected to be in the same folder as the current script) +source "@CMAKE_SOURCE_DIR@/devtools/deploy/mac/shutils.zsh" +#======================================== + # externally given variables pyoutdir="@BA_PY_OUTPUT_DIR@" libdir="@BA_PY_LIBRARY_OUTPUT_DIR@" @@ -122,24 +127,7 @@ lib_id_re='s;.+name[[:blank:]]+.+/('$libname_re')[[:blank:]]+\(.+;\1;p' function dylib_base_id { # return the base library id for the given library ($1) - otool -l "$1" | grep -A2 -F LC_ID_DYLIB | sed -nE $lib_id_re -} - -# use otool to get the 1st-order dependencies of a library (raw output) -function dylib_deps -{ - # obtain the dependencies for a given library ($1); - # remove the name of the library itself from the references. - # Under MacOS, a Mach-O binary sometimes depends on itself. - # otool output example: ' /usr/local/opt/foo.dylib (compatibility ...)' - local basename="${1##*/}" - otool -XL "$1" | grep -vF "$basename" -} - -function rm_list_duplicates -{ - # remove duplicates from a given list, $@; (separator is a space char) - echo $@ | tr -s ' ' '\n' | sort -u | tr '\n' ' ' + otool -XD "$1" | sed -nE $lib_id_re } # get dependencies of a given library on BornAgain libraries @@ -176,7 +164,7 @@ function get_python_dependence # regexp to correct the Python dependence; eg.: # '/usr/local/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/Python' => 'libpython3.9.dylib' # '/usr/local/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/libpython3.9.dylib' => 'libpython3.9.dylib' - pylib_re='s;.*[pP]ython.+[Vv]ersions/([0-9.]+).+(Python|libpython).*;libpython\1.dylib;' + pylib_re='s;.+/(Python|libpython)([0-9.]+)\.dylib;libpython\2.dylib;' # obtain the dependencies pydeps0=$(dylib_deps "$1") pydepends_fullpath=$(echo "$pydeps0" | sed -nE $py_fmwk_re) @@ -186,43 +174,10 @@ function get_python_dependence } -# get Python dependence for a given file -function get_python_framework_path -{ - local pydepends_fullpath=$1 - # regexp to extract the Python framework path - # eg. '/usr/local/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/Python' - # => '/usr/local/opt/python@3.9/Frameworks/Python.framework/Versions/3.9/' - py_fmwk_path_re='s;(.*)/(Python|libpython).*;\1;' - # regexp to extract the Python version; eg. '3.9' - pyversion_re='s;.*[pP]ython.+[Vv]ersions/([0-9.]+).*;\1;' - # obtain the dependencies; remove the first line which is - # the name of the file itself - pyversion=$(echo "$pydepends_fullpath" | sed -E $pyversion_re) - py_fmwk_dir=$(echo "$pydepends_fullpath" | sed -E $py_fmwk_path_re) - # collect RPATHs corresponding to the common framework paths - framework_paths="/usr/local/Library/Frameworks /Library/Frameworks /usr/local/Frameworks" - py_fmwk_path="Python.framework/Versions/$pyversion/lib" - # when the library is like '.../Versions/3.9/Python', then - # add an extra '/lib' to the framework path. - # This is needed since refer always to the Python shared library - # '.../Versions/3.9/lib/libpython39.dylib'. - if [[ $pydepends_fullpath = */Python ]]; then - py_fmwk_dir="$py_fmwk_dir/lib" - fi - py_fmwk_rpaths="$py_fmwk_dir" - for pth in `echo ${=framework_paths}`; do - py_fmwk_rpaths="$py_fmwk_rpaths $pth/$py_fmwk_path" - done - # return a list of possible framework paths - echo $py_fmwk_rpaths -} - - # rename the external libraries to their library ids for lib in $xlibdir/*.dylib*; do lib_id=$(dylib_base_id $lib) - mv -fn "$lib" "$xlibdir/$lib_id" + mv -f "$lib" "$xlibdir/$lib_id" done # gather all dependencies diff --git a/devtools/deploy/mac/shutils.zsh b/devtools/deploy/mac/shutils.zsh index c255009a2fc47762778c25f752b5d527c2694c57..fcb78ac868d173051c3d68a24441b7e8d260a98d 100644 --- a/devtools/deploy/mac/shutils.zsh +++ b/devtools/deploy/mac/shutils.zsh @@ -317,6 +317,26 @@ function get_python_framework_path print $py_fmwk_rpaths } +function get_python_framework_path_rel +# produce proper Python framework paths for a given _relative_ Python dependency ($1) +{ + pydepends_relpath=$1 + # regexp to extract the Python framework path + # eg. '@rpath/libpython39.dylib' => 'libpython39.dylib' + pyversion_re='s;.*/libpython([0-9].[0-9]+).*;\1;' + declare -r pyversion=$(echo "$1" | sed -E $pyversion_re) + # RPATHs corresponding to the common OSX framework paths + declare -r py_fmwk_basepath="Python.framework/Versions/$pyversion/lib" + declare -ar framework_paths=( /usr/local/Library/Frameworks + /Library/Frameworks /usr/local/Frameworks ) + # collect proper RPATHs for Python framework + py_fmwk_rpaths="" + for pth in $framework_paths; do + py_fmwk_rpaths+=" $pth/$py_fmwk_basepath" + done + # return a list of possible framework paths + print $py_fmwk_rpaths +} #======================================== #-- perform tests --