1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356
|
#-----------------------------------------------------------------------------
# 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.
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 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),
)
else:
logger.warning("%s: Tcl module directory %r does not exist!", self, 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()
|