| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- #-----------------------------------------------------------------------------
- # Copyright (c) 2013-2023, PyInstaller Development Team.
- #
- # Distributed under the terms of the GNU General Public License (version 2
- # or later) with exception for distributing the bootloader.
- #
- # The full license is in the file COPYING.txt, distributed with this software.
- #
- # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
- #-----------------------------------------------------------------------------
- import os
- import fnmatch
- from PyInstaller import compat
- from PyInstaller import isolated
- from PyInstaller import log as logging
- from PyInstaller.depend import bindepend
- if compat.is_darwin:
- from PyInstaller.utils import osx as osxutils
- logger = logging.getLogger(__name__)
- @isolated.decorate
- def _get_tcl_tk_info():
- """
- Isolated-subprocess helper to retrieve the basic Tcl/Tk information:
- - tkinter_extension_file = the value of __file__ attribute of the _tkinter binary extension (path to file).
- - tcl_data_dir = path to the Tcl library/data directory.
- - tcl_version = Tcl version
- - tk_version = Tk version
- - tcl_theaded = boolean indicating whether Tcl/Tk is built with multi-threading support.
- """
- try:
- import tkinter
- import _tkinter
- except ImportError:
- # tkinter unavailable
- return None
- try:
- tcl = tkinter.Tcl()
- except tkinter.TclError: # e.g. "Can't find a usable init.tcl in the following directories: ..."
- return None
- # Query the location of Tcl library/data directory.
- tcl_data_dir = tcl.eval("info library")
- # Check if Tcl/Tk is built with multi-threaded support (built with --enable-threads), as indicated by the presence
- # of optional `threaded` member in `tcl_platform` array. Tcl 9.0 removed the --enable-threads flag, and is always
- # built with multi-threaded support (and thus the `threaded` array member has been removed).
- TCL_MAJOR = int(_tkinter.TCL_VERSION.split(".")[0])
- if TCL_MAJOR >= 9:
- tcl_threaded = True
- else:
- try:
- tcl.getvar("tcl_platform(threaded)") # Ignore the actual value.
- tcl_threaded = True
- except tkinter.TclError:
- tcl_threaded = False
- return {
- "available": True,
- # If `_tkinter` is a built-in (as opposed to an extension), it does not have a `__file__` attribute.
- "tkinter_extension_file": getattr(_tkinter, '__file__', None),
- "tcl_version": _tkinter.TCL_VERSION,
- "tk_version": _tkinter.TK_VERSION,
- "tcl_threaded": tcl_threaded,
- "tcl_data_dir": tcl_data_dir,
- }
- class TclTkInfo:
- # Root directory names of Tcl and Tk library/data directories in the frozen application. These directories are
- # originally fully versioned (e.g., tcl8.6 and tk8.6); we want to remap them to unversioned variants, so that our
- # run-time hook (pyi_rthook__tkinter.py) does not have to determine version numbers when setting `TCL_LIBRARY`
- # and `TK_LIBRARY` environment variables.
- #
- # We also cannot use plain "tk" and "tcl", because on macOS, the Tcl and Tk shared libraries might come from
- # framework bundles, and would therefore end up being collected as "Tcl" and "Tk" in the top-level application
- # directory, causing clash due to filesystem being case-insensitive by default.
- TCL_ROOTNAME = '_tcl_data'
- TK_ROOTNAME = '_tk_data'
- def __init__(self):
- pass
- def __repr__(self):
- return "TclTkInfo"
- # Delay initialization of Tcl/Tk information until until the corresponding attributes are first requested.
- def __getattr__(self, name):
- if 'available' in self.__dict__:
- # Initialization was already done, but requested attribute is not available.
- raise AttributeError(name)
- # Load Qt library info...
- self._load_tcl_tk_info()
- # ... and return the requested attribute
- return getattr(self, name)
- def _load_tcl_tk_info(self):
- logger.info("%s: initializing cached Tcl/Tk info...", self)
- # Initialize variables so that they might be accessed even if tkinter/Tcl/Tk is unavailable or if initialization
- # fails for some reason.
- self.available = False
- self.tkinter_extension_file = None
- self.tcl_version = None
- self.tk_version = None
- self.tcl_threaded = False
- self.tcl_data_dir = None
- self.tk_data_dir = None
- self.tcl_module_dir = None
- self.is_macos_system_framework = False
- self.tcl_shared_library = None
- self.tk_shared_library = None
- self.data_files = []
- try:
- tcl_tk_info = _get_tcl_tk_info()
- except Exception as e:
- logger.warning("%s: failed to obtain Tcl/Tk info: %s", self, e)
- return
- # If tkinter could not be imported, `_get_tcl_tk_info` returns None. In such cases, emit a debug message instead
- # of a warning, because this initialization might be triggered by a helper function that is trying to determine
- # availability of `tkinter` by inspecting the `available` attribute.
- if tcl_tk_info is None:
- logger.debug("%s: failed to obtain Tcl/Tk info: tkinter/_tkinter could not be imported.", self)
- return
- # Copy properties
- for key, value in tcl_tk_info.items():
- setattr(self, key, value)
- # Parse Tcl/Tk version into (major, minor) tuple.
- self.tcl_version = tuple((int(x) for x in self.tcl_version.split(".")[:2]))
- self.tk_version = tuple((int(x) for x in self.tk_version.split(".")[:2]))
- # Determine full path to Tcl and Tk shared libraries against which the `_tkinter` extension module is linked.
- # This can only be done when `_tkinter` is in fact an extension, and not a built-in. In the latter case, the
- # Tcl/Tk libraries are statically linked into python shared library, so there are no shared libraries for us
- # to discover.
- if self.tkinter_extension_file:
- try:
- (
- self.tcl_shared_library,
- self.tk_shared_library,
- ) = self._find_tcl_tk_shared_libraries(self.tkinter_extension_file)
- except Exception:
- logger.warning("%s: failed to determine Tcl and Tk shared library location!", self, exc_info=True)
- # macOS: check if _tkinter is linked against system-provided Tcl.framework and Tk.framework. This is the
- # case with python3 from XCode tools (and was the case with very old homebrew python builds). In such cases,
- # we should not be collecting Tcl/Tk files.
- if compat.is_darwin:
- self.is_macos_system_framework = self._check_macos_system_framework(self.tcl_shared_library)
- # Emit a warning in the unlikely event that we are dealing with Teapot-distributed version of ActiveTcl.
- if not self.is_macos_system_framework:
- self._warn_if_using_activetcl_or_teapot(self.tcl_data_dir)
- # Infer location of Tk library/data directory. Ideally, we could infer this by running
- #
- # import tkinter
- # root = tkinter.Tk()
- # tk_data_dir = root.tk.exprstring('$tk_library')
- #
- # in the isolated subprocess as part of `_get_tcl_tk_info`. However, that is impractical, as it shows the empty
- # window, and on some platforms (e.g., linux) requires display server. Therefore, try to guess the location,
- # based on the following heuristic:
- # - if TK_LIBRARY is defined use it.
- # - if Tk is built as macOS framework bundle, look for Scripts sub-directory in Resources directory next to
- # the shared library.
- # - otherwise, look for: $tcl_root/../tkX.Y, where X and Y are Tk major and minor version.
- if "TK_LIBRARY" in os.environ:
- self.tk_data_dir = os.environ["TK_LIBRARY"]
- elif compat.is_darwin and self.tk_shared_library and (
- # is_framework_bundle_lib handles only fully-versioned framework library paths...
- (osxutils.is_framework_bundle_lib(self.tk_shared_library)) or
- # ... so manually handle top-level-symlinked variant for now.
- (self.tk_shared_library).endswith("Tk.framework/Tk")
- ):
- # Fully resolve the library path, in case it is a top-level symlink; for example, resolve
- # /Library/Frameworks/Python.framework/Versions/3.13/Frameworks/Tk.framework/Tk
- # into
- # /Library/Frameworks/Python.framework/Versions/3.13/Frameworks/Tk.framework/Versions/8.6/Tk
- tk_lib_realpath = os.path.realpath(self.tk_shared_library)
- # Resources/Scripts directory next to the shared library
- self.tk_data_dir = os.path.join(os.path.dirname(tk_lib_realpath), "Resources", "Scripts")
- else:
- self.tk_data_dir = os.path.join(
- os.path.dirname(self.tcl_data_dir),
- f"tk{self.tk_version[0]}.{self.tk_version[1]}",
- )
- # Infer location of Tcl module directory. The modules directory is separate from the library/data one, and
- # is located at $tcl_root/../tclX, where X is the major Tcl version. In some Tcl distributions (for example,
- # Debian-packaged Tcl), this directory is located under library/data directory ($tcl_root); in such cases,
- # we do not need to worry about it.
- self.tcl_module_dir = os.path.join(
- os.path.dirname(self.tcl_data_dir),
- f"tcl{self.tcl_version[0]}",
- )
- # Find all data files
- if self.is_macos_system_framework:
- logger.info("%s: using macOS system Tcl/Tk framework - not collecting data files.", self)
- else:
- # Collect Tcl and Tk scripts from their corresponding library/data directories. See comment at the
- # definition of TK_ROOTNAME and TK_ROOTNAME variables.
- if os.path.isdir(self.tcl_data_dir):
- self.data_files += self._collect_files_from_directory(
- self.tcl_data_dir,
- prefix=self.TCL_ROOTNAME,
- excludes=['demos', '*.lib', 'tclConfig.sh'],
- )
- else:
- logger.warning("%s: Tcl library/data directory %r does not exist!", self, self.tcl_data_dir)
- if os.path.isdir(self.tk_data_dir):
- self.data_files += self._collect_files_from_directory(
- self.tk_data_dir,
- prefix=self.TK_ROOTNAME,
- excludes=['demos', '*.lib', 'tkConfig.sh'],
- )
- else:
- logger.warning("%s: Tk library/data directory %r does not exist!", self, self.tk_data_dir)
- # Collect Tcl modules from optional modules directory
- if os.path.isdir(self.tcl_module_dir):
- self.data_files += self._collect_files_from_directory(
- self.tcl_module_dir,
- prefix=os.path.basename(self.tcl_module_dir),
- )
- @staticmethod
- def _collect_files_from_directory(root, prefix=None, excludes=None):
- """
- A minimal port of PyInstaller.building.datastruct.Tree() functionality, which allows us to avoid using Tree
- here. This way, the TclTkInfo data structure can be used without having PyInstaller's config context set up.
- """
- excludes = excludes or []
- todo = [(root, prefix)]
- output = []
- while todo:
- target_dir, prefix = todo.pop()
- for entry in os.listdir(target_dir):
- # Basic name-based exclusion
- if any((fnmatch.fnmatch(entry, exclude) for exclude in excludes)):
- continue
- src_path = os.path.join(target_dir, entry)
- dest_path = os.path.join(prefix, entry) if prefix else entry
- if os.path.isdir(src_path):
- todo.append((src_path, dest_path))
- else:
- # Return 3-element tuples with fully-resolved dest path, since other parts of code depend on that.
- output.append((dest_path, src_path, 'DATA'))
- return output
- @staticmethod
- def _find_tcl_tk_shared_libraries(tkinter_ext_file):
- """
- Find Tcl and Tk shared libraries against which the _tkinter extension module is linked.
- """
- tcl_lib = None
- tk_lib = None
- for _, lib_path in bindepend.get_imports(tkinter_ext_file): # (name, fullpath) tuple
- if lib_path is None:
- continue # Skip unresolved entries
- # For comparison, take basename of lib_path. On macOS, lib_name returned by get_imports is in fact
- # referenced name, which is not necessarily just a basename.
- lib_name = os.path.basename(lib_path)
- lib_name_lower = lib_name.lower() # lower-case for comparisons
- # First check for Tk library, because it is unlikely that 'tk' will appear in the name of the Tcl shared
- # library, while 'tcl' could appear in the name of the Tk shared library. For example, Fedora 43 ships
- # both Tcl/Tk 8.6 and 9.0, and in the latter, the libraries are named `libtcl9.0.so` and `libtcl9tk9.0.so`.
- if 'tk' in lib_name_lower:
- tk_lib = lib_path
- elif 'tcl' in lib_name_lower:
- tcl_lib = lib_path
- return tcl_lib, tk_lib
- @staticmethod
- def _check_macos_system_framework(tcl_shared_lib):
- # Starting with macOS 11, system libraries are hidden (unless both Python and PyInstaller's bootloader are built
- # against macOS 11.x SDK). Therefore, Tcl shared library might end up unresolved (None); but that implicitly
- # indicates that the system framework is used.
- if tcl_shared_lib is None:
- return True
- # Check if the path corresponds to the system framework, i.e., [/System]/Library/Frameworks/Tcl.framework/Tcl
- return 'Library/Frameworks/Tcl.framework' in tcl_shared_lib
- @staticmethod
- def _warn_if_using_activetcl_or_teapot(tcl_root):
- """
- Check if Tcl installation is a Teapot-distributed version of ActiveTcl, and log a non-fatal warning that the
- resulting frozen application will (likely) fail to run on other systems.
- PyInstaller does *not* freeze all ActiveTcl dependencies -- including Teapot, which is typically ignorable.
- Since Teapot is *not* ignorable in this case, this function warns of impending failure.
- See Also
- -------
- https://github.com/pyinstaller/pyinstaller/issues/621
- """
- if tcl_root is None:
- return
- # Read the "init.tcl" script and look for mentions of "activetcl" and "teapot"
- init_tcl = os.path.join(tcl_root, 'init.tcl')
- if not os.path.isfile(init_tcl):
- return
- mentions_activetcl = False
- mentions_teapot = False
- # Tcl/Tk reads files using the system encoding (https://www.tcl.tk/doc/howto/i18n.html#system_encoding);
- # on macOS, this is UTF-8.
- with open(init_tcl, 'r', encoding='utf8') as fp:
- for line in fp.readlines():
- line = line.strip().lower()
- if line.startswith('#'):
- continue
- if 'activetcl' in line:
- mentions_activetcl = True
- if 'teapot' in line:
- mentions_teapot = True
- if mentions_activetcl and mentions_teapot:
- break
- if mentions_activetcl and mentions_teapot:
- logger.warning(
- "You appear to be using an ActiveTcl build of Tcl/Tk, which PyInstaller has\n"
- "difficulty freezing. To fix this, comment out all references to 'teapot' in\n"
- f"{init_tcl!r}\n"
- "See https://github.com/pyinstaller/pyinstaller/issues/621 for more information."
- )
- tcltk_info = TclTkInfo()
|