tcl_tk.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. #-----------------------------------------------------------------------------
  2. # Copyright (c) 2013-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 fnmatch
  13. from PyInstaller import compat
  14. from PyInstaller import isolated
  15. from PyInstaller import log as logging
  16. from PyInstaller.depend import bindepend
  17. if compat.is_darwin:
  18. from PyInstaller.utils import osx as osxutils
  19. logger = logging.getLogger(__name__)
  20. @isolated.decorate
  21. def _get_tcl_tk_info():
  22. """
  23. Isolated-subprocess helper to retrieve the basic Tcl/Tk information:
  24. - tkinter_extension_file = the value of __file__ attribute of the _tkinter binary extension (path to file).
  25. - tcl_data_dir = path to the Tcl library/data directory.
  26. - tcl_version = Tcl version
  27. - tk_version = Tk version
  28. - tcl_theaded = boolean indicating whether Tcl/Tk is built with multi-threading support.
  29. """
  30. try:
  31. import tkinter
  32. import _tkinter
  33. except ImportError:
  34. # tkinter unavailable
  35. return None
  36. try:
  37. tcl = tkinter.Tcl()
  38. except tkinter.TclError: # e.g. "Can't find a usable init.tcl in the following directories: ..."
  39. return None
  40. # Query the location of Tcl library/data directory.
  41. tcl_data_dir = tcl.eval("info library")
  42. # Check if Tcl/Tk is built with multi-threaded support (built with --enable-threads), as indicated by the presence
  43. # of optional `threaded` member in `tcl_platform` array. Tcl 9.0 removed the --enable-threads flag, and is always
  44. # built with multi-threaded support (and thus the `threaded` array member has been removed).
  45. TCL_MAJOR = int(_tkinter.TCL_VERSION.split(".")[0])
  46. if TCL_MAJOR >= 9:
  47. tcl_threaded = True
  48. else:
  49. try:
  50. tcl.getvar("tcl_platform(threaded)") # Ignore the actual value.
  51. tcl_threaded = True
  52. except tkinter.TclError:
  53. tcl_threaded = False
  54. return {
  55. "available": True,
  56. # If `_tkinter` is a built-in (as opposed to an extension), it does not have a `__file__` attribute.
  57. "tkinter_extension_file": getattr(_tkinter, '__file__', None),
  58. "tcl_version": _tkinter.TCL_VERSION,
  59. "tk_version": _tkinter.TK_VERSION,
  60. "tcl_threaded": tcl_threaded,
  61. "tcl_data_dir": tcl_data_dir,
  62. }
  63. class TclTkInfo:
  64. # Root directory names of Tcl and Tk library/data directories in the frozen application. These directories are
  65. # originally fully versioned (e.g., tcl8.6 and tk8.6); we want to remap them to unversioned variants, so that our
  66. # run-time hook (pyi_rthook__tkinter.py) does not have to determine version numbers when setting `TCL_LIBRARY`
  67. # and `TK_LIBRARY` environment variables.
  68. #
  69. # We also cannot use plain "tk" and "tcl", because on macOS, the Tcl and Tk shared libraries might come from
  70. # framework bundles, and would therefore end up being collected as "Tcl" and "Tk" in the top-level application
  71. # directory, causing clash due to filesystem being case-insensitive by default.
  72. TCL_ROOTNAME = '_tcl_data'
  73. TK_ROOTNAME = '_tk_data'
  74. def __init__(self):
  75. pass
  76. def __repr__(self):
  77. return "TclTkInfo"
  78. # Delay initialization of Tcl/Tk information until until the corresponding attributes are first requested.
  79. def __getattr__(self, name):
  80. if 'available' in self.__dict__:
  81. # Initialization was already done, but requested attribute is not available.
  82. raise AttributeError(name)
  83. # Load Qt library info...
  84. self._load_tcl_tk_info()
  85. # ... and return the requested attribute
  86. return getattr(self, name)
  87. def _load_tcl_tk_info(self):
  88. logger.info("%s: initializing cached Tcl/Tk info...", self)
  89. # Initialize variables so that they might be accessed even if tkinter/Tcl/Tk is unavailable or if initialization
  90. # fails for some reason.
  91. self.available = False
  92. self.tkinter_extension_file = None
  93. self.tcl_version = None
  94. self.tk_version = None
  95. self.tcl_threaded = False
  96. self.tcl_data_dir = None
  97. self.tk_data_dir = None
  98. self.tcl_module_dir = None
  99. self.is_macos_system_framework = False
  100. self.tcl_shared_library = None
  101. self.tk_shared_library = None
  102. self.data_files = []
  103. try:
  104. tcl_tk_info = _get_tcl_tk_info()
  105. except Exception as e:
  106. logger.warning("%s: failed to obtain Tcl/Tk info: %s", self, e)
  107. return
  108. # If tkinter could not be imported, `_get_tcl_tk_info` returns None. In such cases, emit a debug message instead
  109. # of a warning, because this initialization might be triggered by a helper function that is trying to determine
  110. # availability of `tkinter` by inspecting the `available` attribute.
  111. if tcl_tk_info is None:
  112. logger.debug("%s: failed to obtain Tcl/Tk info: tkinter/_tkinter could not be imported.", self)
  113. return
  114. # Copy properties
  115. for key, value in tcl_tk_info.items():
  116. setattr(self, key, value)
  117. # Parse Tcl/Tk version into (major, minor) tuple.
  118. self.tcl_version = tuple((int(x) for x in self.tcl_version.split(".")[:2]))
  119. self.tk_version = tuple((int(x) for x in self.tk_version.split(".")[:2]))
  120. # Determine full path to Tcl and Tk shared libraries against which the `_tkinter` extension module is linked.
  121. # This can only be done when `_tkinter` is in fact an extension, and not a built-in. In the latter case, the
  122. # Tcl/Tk libraries are statically linked into python shared library, so there are no shared libraries for us
  123. # to discover.
  124. if self.tkinter_extension_file:
  125. try:
  126. (
  127. self.tcl_shared_library,
  128. self.tk_shared_library,
  129. ) = self._find_tcl_tk_shared_libraries(self.tkinter_extension_file)
  130. except Exception:
  131. logger.warning("%s: failed to determine Tcl and Tk shared library location!", self, exc_info=True)
  132. # macOS: check if _tkinter is linked against system-provided Tcl.framework and Tk.framework. This is the
  133. # case with python3 from XCode tools (and was the case with very old homebrew python builds). In such cases,
  134. # we should not be collecting Tcl/Tk files.
  135. if compat.is_darwin:
  136. self.is_macos_system_framework = self._check_macos_system_framework(self.tcl_shared_library)
  137. # Emit a warning in the unlikely event that we are dealing with Teapot-distributed version of ActiveTcl.
  138. if not self.is_macos_system_framework:
  139. self._warn_if_using_activetcl_or_teapot(self.tcl_data_dir)
  140. # Infer location of Tk library/data directory. Ideally, we could infer this by running
  141. #
  142. # import tkinter
  143. # root = tkinter.Tk()
  144. # tk_data_dir = root.tk.exprstring('$tk_library')
  145. #
  146. # in the isolated subprocess as part of `_get_tcl_tk_info`. However, that is impractical, as it shows the empty
  147. # window, and on some platforms (e.g., linux) requires display server. Therefore, try to guess the location,
  148. # based on the following heuristic:
  149. # - if TK_LIBRARY is defined use it.
  150. # - if Tk is built as macOS framework bundle, look for Scripts sub-directory in Resources directory next to
  151. # the shared library.
  152. # - otherwise, look for: $tcl_root/../tkX.Y, where X and Y are Tk major and minor version.
  153. if "TK_LIBRARY" in os.environ:
  154. self.tk_data_dir = os.environ["TK_LIBRARY"]
  155. elif compat.is_darwin and self.tk_shared_library and (
  156. # is_framework_bundle_lib handles only fully-versioned framework library paths...
  157. (osxutils.is_framework_bundle_lib(self.tk_shared_library)) or
  158. # ... so manually handle top-level-symlinked variant for now.
  159. (self.tk_shared_library).endswith("Tk.framework/Tk")
  160. ):
  161. # Fully resolve the library path, in case it is a top-level symlink; for example, resolve
  162. # /Library/Frameworks/Python.framework/Versions/3.13/Frameworks/Tk.framework/Tk
  163. # into
  164. # /Library/Frameworks/Python.framework/Versions/3.13/Frameworks/Tk.framework/Versions/8.6/Tk
  165. tk_lib_realpath = os.path.realpath(self.tk_shared_library)
  166. # Resources/Scripts directory next to the shared library
  167. self.tk_data_dir = os.path.join(os.path.dirname(tk_lib_realpath), "Resources", "Scripts")
  168. else:
  169. self.tk_data_dir = os.path.join(
  170. os.path.dirname(self.tcl_data_dir),
  171. f"tk{self.tk_version[0]}.{self.tk_version[1]}",
  172. )
  173. # Infer location of Tcl module directory. The modules directory is separate from the library/data one, and
  174. # is located at $tcl_root/../tclX, where X is the major Tcl version. In some Tcl distributions (for example,
  175. # Debian-packaged Tcl), this directory is located under library/data directory ($tcl_root); in such cases,
  176. # we do not need to worry about it.
  177. self.tcl_module_dir = os.path.join(
  178. os.path.dirname(self.tcl_data_dir),
  179. f"tcl{self.tcl_version[0]}",
  180. )
  181. # Find all data files
  182. if self.is_macos_system_framework:
  183. logger.info("%s: using macOS system Tcl/Tk framework - not collecting data files.", self)
  184. else:
  185. # Collect Tcl and Tk scripts from their corresponding library/data directories. See comment at the
  186. # definition of TK_ROOTNAME and TK_ROOTNAME variables.
  187. if os.path.isdir(self.tcl_data_dir):
  188. self.data_files += self._collect_files_from_directory(
  189. self.tcl_data_dir,
  190. prefix=self.TCL_ROOTNAME,
  191. excludes=['demos', '*.lib', 'tclConfig.sh'],
  192. )
  193. else:
  194. logger.warning("%s: Tcl library/data directory %r does not exist!", self, self.tcl_data_dir)
  195. if os.path.isdir(self.tk_data_dir):
  196. self.data_files += self._collect_files_from_directory(
  197. self.tk_data_dir,
  198. prefix=self.TK_ROOTNAME,
  199. excludes=['demos', '*.lib', 'tkConfig.sh'],
  200. )
  201. else:
  202. logger.warning("%s: Tk library/data directory %r does not exist!", self, self.tk_data_dir)
  203. # Collect Tcl modules from optional modules directory
  204. if os.path.isdir(self.tcl_module_dir):
  205. self.data_files += self._collect_files_from_directory(
  206. self.tcl_module_dir,
  207. prefix=os.path.basename(self.tcl_module_dir),
  208. )
  209. @staticmethod
  210. def _collect_files_from_directory(root, prefix=None, excludes=None):
  211. """
  212. A minimal port of PyInstaller.building.datastruct.Tree() functionality, which allows us to avoid using Tree
  213. here. This way, the TclTkInfo data structure can be used without having PyInstaller's config context set up.
  214. """
  215. excludes = excludes or []
  216. todo = [(root, prefix)]
  217. output = []
  218. while todo:
  219. target_dir, prefix = todo.pop()
  220. for entry in os.listdir(target_dir):
  221. # Basic name-based exclusion
  222. if any((fnmatch.fnmatch(entry, exclude) for exclude in excludes)):
  223. continue
  224. src_path = os.path.join(target_dir, entry)
  225. dest_path = os.path.join(prefix, entry) if prefix else entry
  226. if os.path.isdir(src_path):
  227. todo.append((src_path, dest_path))
  228. else:
  229. # Return 3-element tuples with fully-resolved dest path, since other parts of code depend on that.
  230. output.append((dest_path, src_path, 'DATA'))
  231. return output
  232. @staticmethod
  233. def _find_tcl_tk_shared_libraries(tkinter_ext_file):
  234. """
  235. Find Tcl and Tk shared libraries against which the _tkinter extension module is linked.
  236. """
  237. tcl_lib = None
  238. tk_lib = None
  239. for _, lib_path in bindepend.get_imports(tkinter_ext_file): # (name, fullpath) tuple
  240. if lib_path is None:
  241. continue # Skip unresolved entries
  242. # For comparison, take basename of lib_path. On macOS, lib_name returned by get_imports is in fact
  243. # referenced name, which is not necessarily just a basename.
  244. lib_name = os.path.basename(lib_path)
  245. lib_name_lower = lib_name.lower() # lower-case for comparisons
  246. # First check for Tk library, because it is unlikely that 'tk' will appear in the name of the Tcl shared
  247. # library, while 'tcl' could appear in the name of the Tk shared library. For example, Fedora 43 ships
  248. # both Tcl/Tk 8.6 and 9.0, and in the latter, the libraries are named `libtcl9.0.so` and `libtcl9tk9.0.so`.
  249. if 'tk' in lib_name_lower:
  250. tk_lib = lib_path
  251. elif 'tcl' in lib_name_lower:
  252. tcl_lib = lib_path
  253. return tcl_lib, tk_lib
  254. @staticmethod
  255. def _check_macos_system_framework(tcl_shared_lib):
  256. # Starting with macOS 11, system libraries are hidden (unless both Python and PyInstaller's bootloader are built
  257. # against macOS 11.x SDK). Therefore, Tcl shared library might end up unresolved (None); but that implicitly
  258. # indicates that the system framework is used.
  259. if tcl_shared_lib is None:
  260. return True
  261. # Check if the path corresponds to the system framework, i.e., [/System]/Library/Frameworks/Tcl.framework/Tcl
  262. return 'Library/Frameworks/Tcl.framework' in tcl_shared_lib
  263. @staticmethod
  264. def _warn_if_using_activetcl_or_teapot(tcl_root):
  265. """
  266. Check if Tcl installation is a Teapot-distributed version of ActiveTcl, and log a non-fatal warning that the
  267. resulting frozen application will (likely) fail to run on other systems.
  268. PyInstaller does *not* freeze all ActiveTcl dependencies -- including Teapot, which is typically ignorable.
  269. Since Teapot is *not* ignorable in this case, this function warns of impending failure.
  270. See Also
  271. -------
  272. https://github.com/pyinstaller/pyinstaller/issues/621
  273. """
  274. if tcl_root is None:
  275. return
  276. # Read the "init.tcl" script and look for mentions of "activetcl" and "teapot"
  277. init_tcl = os.path.join(tcl_root, 'init.tcl')
  278. if not os.path.isfile(init_tcl):
  279. return
  280. mentions_activetcl = False
  281. mentions_teapot = False
  282. # Tcl/Tk reads files using the system encoding (https://www.tcl.tk/doc/howto/i18n.html#system_encoding);
  283. # on macOS, this is UTF-8.
  284. with open(init_tcl, 'r', encoding='utf8') as fp:
  285. for line in fp.readlines():
  286. line = line.strip().lower()
  287. if line.startswith('#'):
  288. continue
  289. if 'activetcl' in line:
  290. mentions_activetcl = True
  291. if 'teapot' in line:
  292. mentions_teapot = True
  293. if mentions_activetcl and mentions_teapot:
  294. break
  295. if mentions_activetcl and mentions_teapot:
  296. logger.warning(
  297. "You appear to be using an ActiveTcl build of Tcl/Tk, which PyInstaller has\n"
  298. "difficulty freezing. To fix this, comment out all references to 'teapot' in\n"
  299. f"{init_tcl!r}\n"
  300. "See https://github.com/pyinstaller/pyinstaller/issues/621 for more information."
  301. )
  302. tcltk_info = TclTkInfo()