libBundleCloudCompare.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. #!/usr/bin/env python3
  2. from __future__ import annotations
  3. import argparse
  4. import json
  5. import logging
  6. import os
  7. import shutil
  8. import subprocess
  9. import sys
  10. from pathlib import Path
  11. logger = logging.getLogger(__name__)
  12. class CCAppBundleConfig:
  13. output_dependencies: bool
  14. embed_python: bool
  15. cc_bin_path: Path
  16. extra_pathlib: Path
  17. frameworks_path: Path
  18. plugin_path: Path
  19. embedded_python_rootpath: Path
  20. python_version: str # pythonMajor.Minor
  21. base_python_binary: Path # prefix/bin/python
  22. base_python_libs: Path # prefix/lib/pythonMajor.Minor
  23. def __init__(
  24. self,
  25. install_path: Path,
  26. extra_pathlib: Path,
  27. output_dependencies: bool,
  28. embed_python: bool,
  29. ) -> None:
  30. """Construct a configuration.
  31. Args:
  32. ----
  33. install_path (str): Path where CC is "installed".
  34. extra_pathlib (str): A Path where additional libs can be found.
  35. output_dependencies (bool): boolean that control the level of debug. If true some extra
  36. files will be created (macos_bundle_warnings.json macos_bundle_dependencies.json).
  37. embed_python (bool): Whether or not python should be embedded into the bundle.
  38. """
  39. self.output_dependencies = output_dependencies
  40. self.bundle_abs_path = (install_path / "CloudCompare" / "CloudCompare.app").absolute()
  41. self.cc_bin_path = self.bundle_abs_path / "Contents" / "MacOS" / "CloudCompare"
  42. self.extra_pathlib = extra_pathlib
  43. self.frameworks_path = self.bundle_abs_path / "Contents" / "Frameworks"
  44. self.plugin_path = self.bundle_abs_path / "Contents" / "PlugIns"
  45. # If we want to embed Python we populate the needed variables
  46. self.embed_python = embed_python
  47. if embed_python:
  48. self._query_python()
  49. self.embedded_python_rootpath = self.bundle_abs_path / "Contents" / "Resources" / "python"
  50. self.embedded_python_path = self.embedded_python_rootpath / "bin"
  51. self.embedded_python_binary = self.embedded_python_path / "python"
  52. self.embedded_python_libpath = self.embedded_python_rootpath / "lib"
  53. self.embedded_python_lib = self.embedded_python_libpath / f"python{self.python_version}"
  54. self.embedded_python_site_package = self.embedded_python_lib / "site-packages"
  55. def __str__(self) -> str:
  56. """Return a string representation of the class."""
  57. res = (
  58. f"--- Frameworks path: {self.frameworks_path} \n"
  59. f" --- plugin path: {self.plugin_path} \n"
  60. f" --- embeddedPythonPath: {self.embedded_python_path} \n"
  61. f" --- embeddedPython: {self.embedded_python_binary} \n"
  62. f" --- embeddedPythonLibPath: {self.embedded_python_libpath} \n"
  63. f" --- embeddedPythonLib: {self.embedded_python_lib} \n"
  64. f" --- embeddedPythonSiteLibs: {self.embedded_python_site_package} \n"
  65. )
  66. return res
  67. def _query_python(self):
  68. """Query for python paths and configuration."""
  69. self.python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
  70. self.base_python_binary = Path(sys.exec_prefix) / "bin" / "python"
  71. self.base_python_libs = Path(sys.exec_prefix) / "lib" / f"python{self.python_version}"
  72. class CCBundler:
  73. config: CCAppBundleConfig
  74. # dictionary of lib dependencies : key depends on (list of libs) (not recursive)
  75. dependencies: dict[str, list[str]] = dict()
  76. warnings: dict[str, list[str]] = dict()
  77. def __init__(self, config: CCAppBundleConfig) -> None:
  78. """Construct a CCBundler object"""
  79. self.config = config
  80. def bundle(self) -> None:
  81. """Bundle the dependencies into the .app"""
  82. if config.embed_python:
  83. self._embed_python()
  84. libs_found, libs_ex_found, libs_in_plugins = self._collect_dependencies()
  85. self._embed_libraries(libs_found, libs_ex_found, libs_in_plugins)
  86. # output debug files if needed
  87. if self.config.output_dependencies:
  88. logger.info("write debug files (macos_bundle_dependencies.json and macos_bundle_warnings.json)")
  89. with open(
  90. Path.cwd() / "macos_bundle_dependencies.json",
  91. "w",
  92. encoding="utf-8",
  93. ) as f:
  94. json.dump(self.dependencies, f, sort_keys=True, indent=4)
  95. with open(
  96. Path.cwd() / "macos_bundle_warnings.json",
  97. "w",
  98. encoding="utf-8",
  99. ) as f:
  100. json.dump(self.warnings, f, sort_keys=True, indent=4)
  101. def _get_lib_dependencies(self, mainlib: Path) -> tuple[list[str], list[str]]:
  102. """List dependencies of mainlib (using otool -L).
  103. We only look for dependencies with @rpath and @executable_path.
  104. We consider @executable_path being relative to the CloudCompare executable.
  105. We keep record and debug /usr and /System for debug purposes.
  106. Args:
  107. ----
  108. mainlib (Path): Path to a binary (lib, executable)
  109. Returns:
  110. -------
  111. libs (list[Path]): lib @rpath or @executable_path
  112. lib_ex (list[(Path, Path)]): lib @executable_path
  113. """
  114. libs: list[Path] = []
  115. lib_ex: list[Path] = []
  116. warning_libs = []
  117. with subprocess.Popen(["otool", "-L", str(mainlib)], stdout=subprocess.PIPE) as proc:
  118. lines = proc.stdout.readlines()
  119. logger.debug(mainlib)
  120. lines.pop(0) # Drop the first line as it contains the name of the lib / binary
  121. # now first line is LC_ID_DYLIB (should be @rpath/libname)
  122. for line in lines:
  123. vals = line.split()
  124. if len(vals) < 2:
  125. continue
  126. pathlib = vals[0].decode()
  127. logger.debug("->pathlib: %s", pathlib)
  128. if pathlib == self.config.extra_pathlib:
  129. logger.info("%s lib from additional extra pathlib", mainlib)
  130. libs.append(Path(pathlib))
  131. continue
  132. dirs = pathlib.split("/")
  133. # TODO: should be better with startswith
  134. # we are likely to have only @rpath values
  135. if dirs[0] == "@rpath":
  136. libs.append(Path(dirs[1]))
  137. elif dirs[0] == "@loader_path":
  138. logger.warning("%s declares a dependencies with @loader_path, this won't be resolved", mainlib)
  139. elif dirs[0] == "@executable_path":
  140. logger.warning("%s declares a dependencies with @executable_path", mainlib)
  141. # TODO: check if mainlib is in the bundle in order to be sure that
  142. # the executable path is relative to the application
  143. lib_ex.append(
  144. (
  145. mainlib.name,
  146. Path(pathlib.removeprefix("@executable_path/")),
  147. ),
  148. )
  149. elif (dirs[1] != "usr") and (dirs[1] != "System"):
  150. logger.warning("%s depends on undeclared pathlib: %s", mainlib, pathlib)
  151. self.warnings[str(mainlib)] = str(warning_libs)
  152. self.dependencies[mainlib.name] = str(libs)
  153. return libs, lib_ex
  154. @staticmethod
  155. def _get_rpath(binary_path: Path) -> list[str]:
  156. """Retrieve paths stored in LC_RPATH part of the binary.
  157. Paths are expected to be in the form @loader_path/xxx, @executable_path/xxx, or abs/relative paths
  158. Args:
  159. ----
  160. binary_path (Path): Path to a binary (lib, executable)
  161. Returns:
  162. -------
  163. list[str]: rpath list (string representation)
  164. """
  165. rpaths = []
  166. with subprocess.Popen(["otool", "-l", str(binary_path)], stdout=subprocess.PIPE) as proc:
  167. lines = proc.stdout.readlines()
  168. for line in lines:
  169. res = line.decode()
  170. vals = res.split()
  171. if len(vals) > 1 and vals[0] == "path":
  172. rpaths.append(vals[1])
  173. return rpaths
  174. @staticmethod
  175. def _convert_rpaths(binary_path: Path, rpaths: list[str]) -> list[Path]:
  176. """Convert rpaths to absolute paths.
  177. Given a path to a binary (lib, executable) and a list of rpaths, resolve rpaths
  178. and append binary_path to them in order to create putative absolute path to this binary
  179. Args:
  180. ----
  181. binary_path (Path): string representation of the path to a binary (lib, executable)
  182. rpaths (list[str]): List of string representation of rpaths
  183. Returns:
  184. -------
  185. list[Path]: list of putative full / absolute path to the binary
  186. """
  187. dirname_binary = binary_path.parent
  188. abs_paths = []
  189. for rpath in rpaths:
  190. if "@loader_path" in rpath:
  191. vals = rpath.split("/")
  192. abs_path = dirname_binary
  193. if len(vals) > 1:
  194. abs_path = (abs_path / Path("/".join(vals[1:]))).resolve()
  195. else:
  196. # TODO: test if it's an absolute path
  197. abs_path = Path(rpath).resolve()
  198. abs_paths.append(abs_path)
  199. return abs_paths
  200. def _copy_python_env(self) -> None:
  201. """Copy python environment.
  202. Ideally this should be handled by CCPython-Runtime CMake script like in Windows.
  203. """
  204. logger.info("Python: copy distribution in package")
  205. try:
  206. self.config.embedded_python_path.mkdir(parents=True)
  207. self.config.embedded_python_libpath.mkdir()
  208. except OSError:
  209. logger.error(
  210. "Python dir already exists in bundle, please clean your bundle and rerun this script",
  211. )
  212. sys.exit(1)
  213. shutil.copytree(self.config.base_python_libs, self.config.embedded_python_lib)
  214. shutil.copy2(self.config.base_python_binary, self.config.embedded_python_binary)
  215. def _embed_python(self) -> None:
  216. """Embed python distribution dependencies in site-packages.
  217. It copies the pyhton target distribution into the `.app` bundle
  218. and then it collects dependencies and rewrites rpaths
  219. of all the binaries/libraries found inside the distribution's tree.
  220. """
  221. libs_to_check = [self.config.embedded_python_binary]
  222. # results
  223. libs_found = set()
  224. lib_ex_found = set()
  225. python_libs = set() # Lib in python dir
  226. self._copy_python_env()
  227. # --- enumerate all libs inside the dir
  228. # Path.walk() is python 3.12+
  229. for root, _, files in os.walk(self.config.embedded_python_lib):
  230. for name in files:
  231. ext = Path(name).suffix
  232. if ext in (".dylib", ".so"):
  233. library = self.config.embedded_python_lib / root / name
  234. libs_to_check.append(library)
  235. python_libs.add(library)
  236. logger.info("number of libs (.so and .dylib) in embedded Python: %i", len(python_libs))
  237. while len(libs_to_check):
  238. lib2check = libs_to_check.pop(0)
  239. if lib2check in libs_found:
  240. continue
  241. libs_found.add(lib2check)
  242. libs, lib_ex = self._get_lib_dependencies(lib2check)
  243. lib_ex_found.update(lib_ex)
  244. rpaths = CCBundler._get_rpath(lib2check)
  245. abs_rpaths = CCBundler._convert_rpaths(lib2check, rpaths)
  246. if self.config.extra_pathlib not in abs_rpaths:
  247. abs_rpaths.append(self.config.extra_pathlib)
  248. for lib in libs:
  249. if lib.is_absolute():
  250. if lib not in libs_to_check and lib not in libs_found:
  251. libs_to_check.append(lib)
  252. else:
  253. for abs_rp in abs_rpaths:
  254. abs_lib = abs_rp / lib
  255. if abs_lib.is_file():
  256. if abs_lib not in libs_to_check and abs_lib not in libs_found:
  257. libs_to_check.append(abs_lib)
  258. break
  259. logger.info("lib_ex_found to add to Frameworks: %i", len(lib_ex_found))
  260. logger.info("libs_found to add to Frameworks: %i", len(libs_found))
  261. libs_in_framework = set(self.config.frameworks_path.iterdir())
  262. added_to_framework_count = 0
  263. for lib in libs_found:
  264. if lib == self.config.embedded_python_binary: # if it's the Python binary we continue
  265. continue
  266. base = self.config.frameworks_path / lib.name
  267. if base not in libs_in_framework and lib not in python_libs:
  268. shutil.copy2(
  269. lib,
  270. self.config.frameworks_path,
  271. ) # copy libs that are not in framework yet
  272. added_to_framework_count = added_to_framework_count + 1
  273. logger.info("libs added to Frameworks: %i", {added_to_framework_count})
  274. logger.info(" --- Python libs: set rpath to Frameworks, nb libs: %i", len(python_libs))
  275. # Set the rpath to the Frameworks path
  276. # TODO: remove old rpath
  277. deep_sp = len(self.config.embedded_python_lib.parents)
  278. for file in python_libs:
  279. deep_lib_sp = len(file.parents) - deep_sp
  280. rpath = "@loader_path/../../../"
  281. for _ in range(deep_lib_sp):
  282. rpath += "../"
  283. rpath += "Frameworks"
  284. subprocess.run(
  285. ["install_name_tool", "-add_rpath", rpath, str(file)],
  286. check=False,
  287. )
  288. def _collect_dependencies(self):
  289. """Collect dependencies of CloudCompare binary and QT libs
  290. Returns
  291. -------
  292. set[Path]: Libs and binaries found in the collect process.
  293. set[(Path, Path)]: Libs and binaries found with an @executable_path dependency.
  294. set[Path]: Libs and binaries found in the plugin dir.
  295. """
  296. # Searching for CC dependencies
  297. libs_to_check = []
  298. # results
  299. libs_found = set() # Abs path of libs/binaries already checked, candidate for embedding in the bundle
  300. lib_ex_found = set()
  301. libs_in_plugins = set()
  302. logger.info("Adding main executable to the libs to check")
  303. libs_to_check.append(self.config.cc_bin_path)
  304. logger.info("Adding lib already available in Frameworks to the libsToCheck")
  305. for file_path in self.config.frameworks_path.iterdir():
  306. libs_to_check.append(file_path)
  307. logger.info("number of libs already in Frameworks directory: %i", len(libs_to_check))
  308. logger.info("Adding plugins to the libs to check")
  309. for plugin_dir in self.config.plugin_path.iterdir():
  310. if plugin_dir.is_dir() and plugin_dir.suffix != ".app":
  311. for file in plugin_dir.iterdir():
  312. if file.is_file() and file.suffix in (".dylib", ".so"):
  313. libs_to_check.append(file)
  314. libs_in_plugins.add(file)
  315. logger.info("number of libs in PlugIns directory: %i", len(libs_in_plugins))
  316. logger.info("searching for dependencies...")
  317. while len(libs_to_check):
  318. # --- Unstack a binary/lib from the libs_to_check array
  319. lib2check = libs_to_check.pop(0)
  320. # If the lib was already processed we continue, of course
  321. if lib2check in libs_found:
  322. continue
  323. # Add the current lib to the already processed libs
  324. libs_found.add(lib2check)
  325. # search for @rpath and @executable_path dependencies in the current lib
  326. lib_deps, lib_ex = self._get_lib_dependencies(lib2check)
  327. # @executable_path are handled in a seperate set
  328. lib_ex_found.update(lib_ex)
  329. # TODO: group these two functions since we do not need
  330. # get all rpath for the current lib
  331. rpaths_str = CCBundler._get_rpath(lib2check)
  332. # get absolute path from found rpath
  333. abs_search_paths = CCBundler._convert_rpaths(lib2check, rpaths_str)
  334. # If the extra_pathlib is not already added, we ad it
  335. # TODO:: there is no way it can be False
  336. # maybe we should prefer to check for authorized lib_dir
  337. # TODO: if rpath is @loader_path, LIB is either in frameworks (already embedded) or in extra_pathlib
  338. # we can take advantage of that...
  339. if self.config.extra_pathlib not in abs_search_paths:
  340. abs_search_paths.append(self.config.extra_pathlib)
  341. # TODO: check if exists, else throw and exception
  342. for dependency in lib_deps:
  343. for abs_rp in abs_search_paths:
  344. abslib_path = abs_rp / dependency
  345. if abslib_path.is_file():
  346. if abslib_path not in libs_to_check and abslib_path not in libs_found:
  347. # if this lib was not checked for dependencies yet, we append it to the list of lib to check
  348. libs_to_check.append(abslib_path)
  349. break
  350. # TODO: handle lib_ex here
  351. # for dependency in lib_ex:...
  352. # TODO: add to libTOcheck executable_path/dep
  353. return libs_found, lib_ex_found, libs_in_plugins
  354. def _embed_libraries(
  355. self,
  356. libs_found: set[Path],
  357. lib_ex_found: set[(Path, Path)],
  358. libs_in_plugins: set[Path],
  359. ) -> None:
  360. """Embed collected libraries into the `.app` bundle.
  361. rpath of embedded libs is modified to match their new location
  362. Args:
  363. ----
  364. libs_found (set[Path]): libs and binaries found in the collect process.
  365. libs_ex_found (set[(Path, Path)]): libs and binaries found with an @executable_path dependency.
  366. libs_found (set[Path]): libs and binaries found in the plugin dir.
  367. """
  368. logger.info("Copying libraries")
  369. logger.info("lib_ex_found to add to Frameworks: %i", len(lib_ex_found))
  370. logger.info("libs_found to add to Frameworks: %i", len(libs_found))
  371. libs_in_frameworks = set(self.config.frameworks_path.iterdir())
  372. nb_libs_added = 0
  373. for lib in libs_found:
  374. if lib == self.config.cc_bin_path:
  375. continue
  376. base = self.config.frameworks_path / lib.name
  377. if (base not in libs_in_frameworks) and (lib not in libs_in_plugins):
  378. shutil.copy2(lib, self.config.frameworks_path)
  379. nb_libs_added += 1
  380. logger.info("number of libs added to Frameworks: %i", {nb_libs_added})
  381. # --- ajout des rpath pour les libraries du framework : framework et ccPlugins
  382. logger.info(" --- Frameworks libs: add rpath to Frameworks")
  383. nb_frameworks_libs = 0
  384. # TODO: purge old rpath
  385. for file in self.config.frameworks_path.iterdir():
  386. if file.is_file() and file.suffix in (".so", ".dylib"):
  387. nb_frameworks_libs += 1
  388. subprocess.run(
  389. ["install_name_tool", "-add_rpath", "@loader_path", str(file)],
  390. stdout=subprocess.PIPE,
  391. check=False,
  392. )
  393. logger.info("number of Frameworks libs with rpath modified: %i", nb_frameworks_libs)
  394. logger.info(" --- PlugIns libs: add rpath to Frameworks, number of libs: %i", len(libs_in_plugins))
  395. for file in libs_in_plugins:
  396. if file.is_file():
  397. subprocess.run(
  398. ["install_name_tool", "-add_rpath", "@loader_path/../../Frameworks", str(file)],
  399. stdout=subprocess.PIPE,
  400. check=False,
  401. )
  402. # TODO: make a function for this
  403. # Embed libs with an @executable_path dependencies
  404. for lib_ex in lib_ex_found:
  405. base = lib_ex[0]
  406. target = lib_ex[1]
  407. framework_path = self.config.frameworks_path / base
  408. plugin_path = self.config.plugin_path / "ccPlugins" / base
  409. if framework_path.is_file():
  410. base_path = framework_path
  411. elif plugin_path.is_file():
  412. base_path = plugin_path
  413. else:
  414. # This should not be possible
  415. raise Exception("no base path")
  416. sys.exit(1)
  417. logger.info("modify : @executable_path -> @rpath: %s", base_path)
  418. subprocess.run(
  419. [
  420. "install_name_tool",
  421. "-change",
  422. "@executable_path/" + str(target),
  423. "@rpath/" + str(target),
  424. str(base_path),
  425. ],
  426. stdout=subprocess.PIPE,
  427. check=False,
  428. )
  429. if __name__ == "__main__":
  430. # configure logger
  431. formatter = " BundleCC::%(levelname)-8s:: %(message)s"
  432. logging.basicConfig(level=logging.INFO, format=formatter)
  433. std_handler = logging.StreamHandler()
  434. # CLI parser
  435. parser = argparse.ArgumentParser("CCAppBundle")
  436. parser.add_argument(
  437. "install_path",
  438. help="Path where the CC application is installed (CMake install dir)",
  439. type=Path,
  440. )
  441. parser.add_argument(
  442. "--extra_pathlib",
  443. help="Extra path to find libraries (default to $CONDA_PREFIX/lib)",
  444. type=Path,
  445. )
  446. parser.add_argument(
  447. "--embed_python",
  448. help="Whether embedding python or not",
  449. action="store_true",
  450. )
  451. parser.add_argument(
  452. "--output_dependencies",
  453. help="Output a json files in order to debug dependency graph",
  454. action="store_true",
  455. )
  456. arguments = parser.parse_args()
  457. # convert extra_pathlib to absolute paths
  458. if arguments.extra_pathlib is not None:
  459. extra_pathlib = arguments.extra_pathlib.resolve()
  460. else:
  461. conda_prefix = os.environ.get("CONDA_PREFIX")
  462. if conda_prefix is not None:
  463. extra_pathlib = (Path(conda_prefix) / "lib").resolve()
  464. else:
  465. logger.error(
  466. "Unable to find CONDA_PREFIX system variable, please run this script inside a conda environment or use the extra_pathlib option.",
  467. )
  468. sys.exit(1)
  469. config = CCAppBundleConfig(
  470. arguments.install_path,
  471. extra_pathlib,
  472. arguments.output_dependencies,
  473. arguments.embed_python,
  474. )
  475. bundler = CCBundler(config)
  476. bundler.bundle()