gi.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. # ----------------------------------------------------------------------------
  2. # Copyright (c) 2005-2023, PyInstaller Development Team.
  3. #
  4. # Distributed under the terms of the GNU General Public License (version 2
  5. # or later) with exception for distributing the bootloader.
  6. #
  7. # The full license is in the file COPYING.txt, distributed with this software.
  8. #
  9. # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
  10. # ----------------------------------------------------------------------------
  11. import os
  12. import pathlib
  13. import shutil
  14. import subprocess
  15. import hashlib
  16. import re
  17. from PyInstaller.depend.utils import _resolveCtypesImports
  18. from PyInstaller.utils.hooks import collect_submodules, collect_system_data_files, get_hook_config
  19. from PyInstaller import isolated
  20. from PyInstaller import log as logging
  21. from PyInstaller import compat
  22. from PyInstaller.depend.bindepend import findSystemLibrary
  23. logger = logging.getLogger(__name__)
  24. class GiModuleInfo:
  25. def __init__(self, module, version, hook_api=None):
  26. self.name = module
  27. self.version = version
  28. self.available = False
  29. self.sharedlibs = []
  30. self.typelib = None
  31. self.dependencies = []
  32. # If hook API is available, use it to override the version from hookconfig.
  33. if hook_api is not None:
  34. module_versions = get_hook_config(hook_api, 'gi', 'module-versions')
  35. if module_versions:
  36. self.version = module_versions.get(module, version)
  37. logger.debug("Gathering GI module info for %s %s", module, self.version)
  38. @isolated.decorate
  39. def _get_module_info(module, version):
  40. import gi
  41. # Ideally, we would use gi.Repository, which provides common abstraction for some of the functions we use in
  42. # this codepath (e.g., `require`, `get_typelib_path`, `get_immediate_dependencies`). However, it lacks the
  43. # `get_shared_library` function, which is why we are using "full" bindings via `gi.repository.GIRepository`.
  44. #
  45. # PyGObject 3.52.0 switched from girepository-1.0 to girepository-2.0, which means that GIRepository version
  46. # has changed from 2.0 to 3.0 and some of the API has changed.
  47. try:
  48. gi.require_version("GIRepository", "3.0")
  49. new_api = True
  50. except ValueError:
  51. gi.require_version("GIRepository", "2.0")
  52. new_api = False
  53. from gi.repository import GIRepository
  54. # The old API had `get_default` method to obtain global singleton object; it was removed in the new API,
  55. # which requires creation of separate GIRepository instances.
  56. if new_api:
  57. repo = GIRepository.Repository()
  58. try:
  59. repo.require(module, version, GIRepository.RepositoryLoadFlags.LAZY)
  60. except ValueError:
  61. return None # Module not available
  62. # The new API returns the list of shared libraries.
  63. sharedlibs = repo.get_shared_libraries(module)
  64. else:
  65. repo = GIRepository.Repository.get_default()
  66. try:
  67. repo.require(module, version, GIRepository.RepositoryLoadFlags.IREPOSITORY_LOAD_FLAG_LAZY)
  68. except ValueError:
  69. return None # Module not available
  70. # Shared library/libraries
  71. # Comma-separated list of paths to shared libraries, or None if none are associated. Convert to list.
  72. sharedlibs = repo.get_shared_library(module)
  73. sharedlibs = [lib.strip() for lib in sharedlibs.split(",")] if sharedlibs else []
  74. # Path to .typelib file
  75. typelib = repo.get_typelib_path(module)
  76. # Dependencies
  77. # GIRepository.Repository.get_immediate_dependencies is available from gobject-introspection v1.44 on
  78. if hasattr(repo, 'get_immediate_dependencies'):
  79. dependencies = repo.get_immediate_dependencies(module)
  80. else:
  81. dependencies = repo.get_dependencies(module)
  82. return {
  83. 'sharedlibs': sharedlibs,
  84. 'typelib': typelib,
  85. 'dependencies': dependencies,
  86. }
  87. # Try to query information; if this fails, mark module as unavailable.
  88. try:
  89. info = _get_module_info(module, self.version)
  90. if info is None:
  91. logger.debug("GI module info %s %s not found.", module, self.version)
  92. else:
  93. logger.debug("GI module info %s %s found.", module, self.version)
  94. self.sharedlibs = info['sharedlibs']
  95. self.typelib = info['typelib']
  96. self.dependencies = info['dependencies']
  97. self.available = True
  98. except Exception as e:
  99. logger.warning("Failed to query GI module %s %s: %s", module, self.version, e)
  100. def get_libdir(self):
  101. """
  102. Return the path to shared library used by the module. If no libraries are associated with the typelib, None is
  103. returned. If multiple library names are associated with the typelib, the path to the first resolved shared
  104. library is returned. Raises exception if module is unavailable or none of the shared libraries could be
  105. resolved.
  106. """
  107. # Module unavailable
  108. if not self.available:
  109. raise ValueError(f"Module {self.name} {self.version} is unavailable!")
  110. # Module has no associated shared libraries
  111. if not self.sharedlibs:
  112. return None
  113. for lib in self.sharedlibs:
  114. path = findSystemLibrary(lib)
  115. if path:
  116. return os.path.normpath(os.path.dirname(path))
  117. raise ValueError(f"Could not resolve any shared library of {self.name} {self.version}: {self.sharedlibs}!")
  118. def collect_typelib_data(self):
  119. """
  120. Return a tuple of (binaries, datas, hiddenimports) to be used by PyGObject related hooks.
  121. """
  122. datas = []
  123. binaries = []
  124. hiddenimports = []
  125. logger.debug("Collecting module data for %s %s", self.name, self.version)
  126. # Module unavailable
  127. if not self.available:
  128. raise ValueError(f"Module {self.name} {self.version} is unavailable!")
  129. # Find shared libraries
  130. resolved_libs = _resolveCtypesImports(self.sharedlibs)
  131. for resolved_lib in resolved_libs:
  132. logger.debug("Collecting shared library %s at %s", resolved_lib[0], resolved_lib[1])
  133. binaries.append((resolved_lib[1], "."))
  134. # Find and collect .typelib file. Run it through the `gir_library_path_fix` to fix the library path, if
  135. # necessary.
  136. typelib_entry = gir_library_path_fix(self.typelib)
  137. if typelib_entry:
  138. logger.debug('Collecting gir typelib at %s', typelib_entry[0])
  139. datas.append(typelib_entry)
  140. # Overrides for the module
  141. hiddenimports += collect_submodules('gi.overrides', lambda name: name.endswith('.' + self.name))
  142. # Module dependencies
  143. for dep in self.dependencies:
  144. dep_module, _ = dep.rsplit('-', 1)
  145. hiddenimports += [f'gi.repository.{dep_module}']
  146. return binaries, datas, hiddenimports
  147. # The old function, provided for backwards compatibility in 3rd party hooks.
  148. def get_gi_libdir(module, version):
  149. module_info = GiModuleInfo(module, version)
  150. return module_info.get_libdir()
  151. # The old function, provided for backwards compatibility in 3rd party hooks.
  152. def get_gi_typelibs(module, version):
  153. """
  154. Return a tuple of (binaries, datas, hiddenimports) to be used by PyGObject related hooks. Searches for and adds
  155. dependencies recursively.
  156. :param module: GI module name, as passed to 'gi.require_version()'
  157. :param version: GI module version, as passed to 'gi.require_version()'
  158. """
  159. module_info = GiModuleInfo(module, version)
  160. return module_info.collect_typelib_data()
  161. def gir_library_path_fix(path):
  162. import subprocess
  163. # 'PyInstaller.config' cannot be imported as other top-level modules.
  164. from PyInstaller.config import CONF
  165. path = os.path.abspath(path)
  166. # On macOS we need to recompile the GIR files to reference the loader path,
  167. # but this is not necessary on other platforms.
  168. if compat.is_darwin:
  169. # If using a virtualenv, the base prefix and the path of the typelib
  170. # have really nothing to do with each other, so try to detect that.
  171. common_path = os.path.commonprefix([compat.base_prefix, path])
  172. if common_path == '/':
  173. logger.debug("virtualenv detected? fixing the gir path...")
  174. common_path = os.path.abspath(os.path.join(path, '..', '..', '..'))
  175. gir_path = os.path.join(common_path, 'share', 'gir-1.0')
  176. typelib_name = os.path.basename(path)
  177. gir_name = os.path.splitext(typelib_name)[0] + '.gir'
  178. gir_file = os.path.join(gir_path, gir_name)
  179. if not os.path.exists(gir_path):
  180. logger.error(
  181. "Unable to find gir directory: %s.\nTry installing your platform's gobject-introspection package.",
  182. gir_path
  183. )
  184. return None
  185. if not os.path.exists(gir_file):
  186. logger.error(
  187. "Unable to find gir file: %s.\nTry installing your platform's gobject-introspection package.", gir_file
  188. )
  189. return None
  190. with open(gir_file, 'r', encoding='utf-8') as f:
  191. lines = f.readlines()
  192. # GIR files are `XML encoded <https://developer.gnome.org/gi/stable/gi-gir-reference.html>`_,
  193. # which means they are by definition encoded using UTF-8.
  194. with open(os.path.join(CONF['workpath'], gir_name), 'w', encoding='utf-8') as f:
  195. for line in lines:
  196. if 'shared-library' in line:
  197. split = re.split('(=)', line)
  198. files = re.split('(["|,])', split[2])
  199. for count, item in enumerate(files):
  200. if 'lib' in item:
  201. files[count] = '@loader_path/' + os.path.basename(item)
  202. line = ''.join(split[0:2]) + ''.join(files)
  203. f.write(line)
  204. # g-ir-compiler expects a file so we cannot just pipe the fixed file to it.
  205. command = subprocess.Popen((
  206. 'g-ir-compiler', os.path.join(CONF['workpath'], gir_name),
  207. '-o', os.path.join(CONF['workpath'], typelib_name)
  208. )) # yapf: disable
  209. command.wait()
  210. return os.path.join(CONF['workpath'], typelib_name), 'gi_typelibs'
  211. else:
  212. return path, 'gi_typelibs'
  213. @isolated.decorate
  214. def get_glib_system_data_dirs():
  215. import gi
  216. gi.require_version('GLib', '2.0')
  217. from gi.repository import GLib
  218. return GLib.get_system_data_dirs()
  219. def get_glib_sysconf_dirs():
  220. """
  221. Try to return the sysconf directories (e.g., /etc).
  222. """
  223. if compat.is_win:
  224. # On Windows, if you look at gtkwin32.c, sysconfdir is actually relative to the location of the GTK DLL. Since
  225. # that is what we are actually interested in (not the user path), we have to do that the hard way...
  226. return [os.path.join(get_gi_libdir('GLib', '2.0'), 'etc')]
  227. @isolated.call
  228. def data_dirs():
  229. import gi
  230. gi.require_version('GLib', '2.0')
  231. from gi.repository import GLib
  232. return GLib.get_system_config_dirs()
  233. return data_dirs
  234. def collect_glib_share_files(*path):
  235. """
  236. Path is relative to the system data directory (e.g., /usr/share).
  237. """
  238. glib_data_dirs = get_glib_system_data_dirs()
  239. if glib_data_dirs is None:
  240. return []
  241. destdir = os.path.join('share', *path)
  242. # TODO: will this return too much?
  243. collected = []
  244. for data_dir in glib_data_dirs:
  245. p = os.path.join(data_dir, *path)
  246. collected += collect_system_data_files(p, destdir=destdir, include_py_files=False)
  247. return collected
  248. def collect_glib_etc_files(*path):
  249. """
  250. Path is relative to the system config directory (e.g., /etc).
  251. """
  252. glib_config_dirs = get_glib_sysconf_dirs()
  253. if glib_config_dirs is None:
  254. return []
  255. destdir = os.path.join('etc', *path)
  256. # TODO: will this return too much?
  257. collected = []
  258. for config_dir in glib_config_dirs:
  259. p = os.path.join(config_dir, *path)
  260. collected += collect_system_data_files(p, destdir=destdir, include_py_files=False)
  261. return collected
  262. _glib_translations = None
  263. def collect_glib_translations(prog, lang_list=None):
  264. """
  265. Return a list of translations in the system locale directory whose names equal prog.mo.
  266. """
  267. global _glib_translations
  268. if _glib_translations is None:
  269. if lang_list is not None:
  270. trans = []
  271. for lang in lang_list:
  272. trans += collect_glib_share_files(os.path.join("locale", lang))
  273. _glib_translations = trans
  274. else:
  275. _glib_translations = collect_glib_share_files('locale')
  276. names = [os.sep + prog + '.mo', os.sep + prog + '.po']
  277. namelen = len(names[0])
  278. return [(src, dst) for src, dst in _glib_translations if src[-namelen:] in names]
  279. # Not a hook utility function per-se (used by main Analysis class), but kept here to have all GLib/GObject functions
  280. # in one place...
  281. def compile_glib_schema_files(datas_toc, workdir, collect_source_files=False):
  282. """
  283. Compile collected GLib schema files. Extracts the list of GLib schema files from the given input datas TOC, copies
  284. them to temporary working directory, and compiles them. The resulting `gschemas.compiled` file is added to the
  285. output TOC, replacing any existing entry with that name. If `collect_source_files` flag is set, the source XML
  286. schema files are also (re)added to the output TOC; by default, they are not. This function is no-op (returns the
  287. original TOC) if no GLib schemas are found in TOC or if `glib-compile-schemas` executable is not found in `PATH`.
  288. """
  289. SCHEMA_DEST_DIR = pathlib.PurePath("share/glib-2.0/schemas")
  290. workdir = pathlib.Path(workdir)
  291. schema_files = []
  292. output_toc = []
  293. for toc_entry in datas_toc:
  294. dest_name, src_name, typecode = toc_entry
  295. dest_name = pathlib.PurePath(dest_name)
  296. src_name = pathlib.PurePath(src_name)
  297. # Pass-through for non-schema files, identified based on the destination directory.
  298. if dest_name.parent != SCHEMA_DEST_DIR:
  299. output_toc.append(toc_entry)
  300. continue
  301. # It seems schemas directory contains different files with different suffices:
  302. # - .gschema.xml
  303. # - .schema.override
  304. # - .enums.xml
  305. # To avoid omitting anything, simply collect everything into temporary directory.
  306. # Exemptions are gschema.dtd (which should be unnecessary) and gschemas.compiled (which we will generate
  307. # ourselves in this function).
  308. if src_name.name in {"gschema.dtd", "gschemas.compiled"}:
  309. continue
  310. schema_files.append(src_name)
  311. # If there are no schema files available, simply return the input datas TOC.
  312. if not schema_files:
  313. return datas_toc
  314. # Ensure that `glib-compile-schemas` executable is in PATH, just in case...
  315. schema_compiler_exe = shutil.which('glib-compile-schemas')
  316. if not schema_compiler_exe:
  317. logger.warning("GLib schema compiler (glib-compile-schemas) not found! Skipping GLib schema recompilation...")
  318. return datas_toc
  319. # If `gschemas.compiled` file already exists in the temporary working directory, record its modification time and
  320. # hash. This will allow us to restore the modification time on the newly-compiled copy, if the latter turns out
  321. # to be identical to the existing old one. Just in case, if the file becomes subject to timestamp-based caching
  322. # mechanism.
  323. compiled_file = workdir / "gschemas.compiled"
  324. old_compiled_file_hash = None
  325. old_compiled_file_stat = None
  326. if compiled_file.is_file():
  327. # Record creation/modification time
  328. old_compiled_file_stat = compiled_file.stat()
  329. # Compute SHA1 hash; since compiled schema files are relatively small, do it in single step.
  330. old_compiled_file_hash = hashlib.sha1(compiled_file.read_bytes()).digest()
  331. # Ensure that temporary working directory exists, and is empty.
  332. if workdir.exists():
  333. shutil.rmtree(workdir)
  334. workdir.mkdir(exist_ok=True)
  335. # Copy schema (source) files to temporary working directory
  336. for schema_file in schema_files:
  337. shutil.copy(schema_file, workdir)
  338. # Compile. The glib-compile-schema might produce warnings on its own (e.g., schemas using deprecated paths, or
  339. # overrides for non-existent keys). Since these are non-actionable, capture and display them only as a DEBUG
  340. # message, or as a WARNING one if the command fails.
  341. logger.info("Compiling collected GLib schema files in %r...", str(workdir))
  342. try:
  343. cmd_args = [schema_compiler_exe, str(workdir), '--targetdir', str(workdir)]
  344. p = subprocess.run(
  345. cmd_args,
  346. stdin=subprocess.DEVNULL,
  347. stdout=subprocess.PIPE,
  348. stderr=subprocess.STDOUT,
  349. check=True,
  350. errors='ignore',
  351. encoding='utf-8',
  352. )
  353. logger.debug("Output from glib-compile-schemas:\n%s", p.stdout)
  354. except subprocess.CalledProcessError as e:
  355. # The called glib-compile-schema returned error. Display stdout/stderr, and return original datas TOC to
  356. # minimize damage.
  357. logger.warning("Failed to recompile GLib schemas! Returning collected files as-is!", exc_info=True)
  358. logger.warning("Output from glib-compile-schemas:\n%s", e.stdout)
  359. return datas_toc
  360. except Exception:
  361. # Compilation failed for whatever reason. Return original datas TOC to minimize damage.
  362. logger.warning("Failed to recompile GLib schemas! Returning collected files as-is!", exc_info=True)
  363. return datas_toc
  364. # Compute the checksum of the new compiled file, and if it matches the old checksum, restore the modification time.
  365. if old_compiled_file_hash is not None:
  366. new_compiled_file_hash = hashlib.sha1(compiled_file.read_bytes()).digest()
  367. if new_compiled_file_hash == old_compiled_file_hash:
  368. os.utime(compiled_file, ns=(old_compiled_file_stat.st_atime_ns, old_compiled_file_stat.st_mtime_ns))
  369. # Add the resulting gschemas.compiled file to the output TOC
  370. output_toc.append((str(SCHEMA_DEST_DIR / compiled_file.name), str(compiled_file), "DATA"))
  371. # Include source schema files in the output TOC (optional)
  372. if collect_source_files:
  373. for schema_file in schema_files:
  374. output_toc.append((str(SCHEMA_DEST_DIR / schema_file.name), str(schema_file), "DATA"))
  375. return output_toc