zmc
2023-08-08 e792e9a60d958b93aef96050644f369feb25d61b
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
#-----------------------------------------------------------------------------
# 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 locale
import os
 
from PyInstaller import compat
from PyInstaller import isolated
from PyInstaller import log as logging
from PyInstaller.building.datastruct import Tree
from PyInstaller.depend import bindepend
 
logger = logging.getLogger(__name__)
 
TK_ROOTNAME = 'tk'
TCL_ROOTNAME = 'tcl'
 
 
@isolated.decorate
def _get_tcl_tk_info():
    """
    Isolated-subprocess helper to retrieve the basic Tcl/Tk information:
     - tcl_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
        from _tkinter import TCL_VERSION, TK_VERSION
    except ImportError:
        # tkinter unavailable
        return None, None, None, False
 
    tcl = tkinter.Tcl()
 
    # Query the location of Tcl library/data directory.
    tcl_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.
    try:
        tcl.getvar("tcl_platform(threaded)")  # Ignore the actual value.
        tcl_threaded = True
    except tkinter.TclError:
        tcl_threaded = False
 
    return tcl_dir, TCL_VERSION, TK_VERSION, tcl_threaded
 
 
# Populate the variables. If `tkinter` is unavailable, the values are set to `None` or `False`.
(
    tcl_dir,
    tcl_version,
    tk_version,
    tcl_threaded,
) = _get_tcl_tk_info()
 
 
def _warn_if_activetcl_or_teapot_installed(tcl_root, tcltree):
    """
    If the current Tcl installation is a Teapot-distributed version of ActiveTcl *and* the current platform is macOS,
    log a non-fatal warning that the resulting executable will (probably) fail to run on non-host 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
    """
    import macholib.util
 
    # System libraries do not experience this problem.
    if macholib.util.in_system_path(tcl_root):
        return
 
    # Absolute path of the "init.tcl" script.
    try:
        init_resource = [r[1] for r in tcltree if r[1].endswith('init.tcl')][0]
    except IndexError:
        # If such script could not be found, silently return.
        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
    with open(init_resource, 'r', encoding=locale.getpreferredencoding()) as init_file:
        for line in init_file.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
difficulty freezing. To fix this, comment out all references to "teapot" in:
 
     %s
 
See https://github.com/pyinstaller/pyinstaller/issues/621 for more information.
            """ % init_resource
        )
 
 
def find_tcl_tk_shared_libs(tkinter_ext_file):
    """
    Find Tcl and Tk shared libraries against which the _tkinter module is linked.
 
    Returns
    -------
    list
        list containing two tuples, one for Tcl and one for Tk library, where each tuple contains the library name and
        its full path, i.e., [(tcl_lib, tcl_libpath), (tk_lib, tk_libpath)]. If a library is not found, the
        corresponding tuple elements are set to None.
    """
    tcl_lib = None
    tcl_libpath = None
    tk_lib = None
    tk_libpath = None
 
    # Do not use bindepend.selectImports, as it ignores libraries seen during previous invocations.
    _tkinter_imports = bindepend.getImports(tkinter_ext_file)
 
    def _get_library_path(lib):
        if compat.is_win:
            # On Windows, we need to resolve full path to the library.
            path = bindepend.getfullnameof(lib)
        else:
            # Non-Windows systems (including Cygwin) already return full path to the library.
            path = lib
        return path
 
    for lib in _tkinter_imports:
        # On some platforms, full path to the shared library is returned. So check only basename to prevent false
        # positive matches due to words tcl or tk being contained in the path.
        lib_name = os.path.basename(lib)
        lib_name_lower = lib_name.lower()  # lower-case for comparisons
 
        if 'tcl' in lib_name_lower:
            tcl_lib = lib_name
            tcl_libpath = _get_library_path(lib)
        elif 'tk' in lib_name_lower:
            tk_lib = lib_name
            tk_libpath = _get_library_path(lib)
 
    return [(tcl_lib, tcl_libpath), (tk_lib, tk_libpath)]
 
 
def _find_tcl_tk(tkinter_ext_file):
    """
    Get a platform-specific 2-tuple of the absolute paths of the top-level external data directories for both
    Tcl and Tk, respectively.
 
    Returns
    -------
    list
        2-tuple that contains the values of `${TCL_LIBRARY}` and `${TK_LIBRARY}`, respectively.
    """
    if compat.is_darwin:
        # On macOS, _tkinter extension is linked either against the system Tcl/Tk framework (older homebrew python,
        # python3 from XCode tools) or against bundled Tcl/Tk library (recent python.org builds, recent homebrew
        # python with python-tk). PyInstaller does not bundle data from system frameworks (as it does not not collect
        # shared libraries from them, either), so we need to determine what kind of Tcl/Tk we are dealing with.
        libs = find_tcl_tk_shared_libs(tkinter_ext_file)
 
        # Check the full path to the Tcl library.
        path_to_tcl = libs[0][1]
 
        # Starting with macOS 11, system libraries are hidden (unless both Python and PyInstaller's bootloader are built
        # against MacOS 11.x SDK). Therefore, libs may end up empty; but that implicitly indicates that the system
        # framework is used, so return (None, None) to inform the caller.
        if path_to_tcl is None:
            return None, None
 
        # Check if the path corresponds to the system framework, i.e., [/System]/Library/Frameworks/Tcl.framework/Tcl
        if 'Library/Frameworks/Tcl.framework' in path_to_tcl:
            return None, None  # Do not collect system framework's data.
 
        # Bundled copy of Tcl/Tk; in this case, the dynamic library is
        # /Library/Frameworks/Python.framework/Versions/3.x/lib/libtcl8.6.dylib
        # and the data directories have standard layout that is handled by code below.
    else:
        # On Windows and linux, data directories have standard layout that is handled by code below.
        pass
 
    # The Tcl library location is already stored in `tcl_dir` global variable. The Tk library is in the same prefix, so
    # construct the path using `tk_version` global variable.
    tk_dir = os.path.join(os.path.dirname(tcl_dir), f"tk{tk_version}")
    return tcl_dir, tk_dir
 
 
def _collect_tcl_modules(tcl_root):
    """
    Get a list of TOC-style 3-tuples describing Tcl modules. The modules directory is separate from the library/data
    one, and is located at $tcl_root/../tclX, where X is the major Tcl version.
 
    Returns
    -------
    Tree
        Such list, if the modules directory exists.
    """
 
    # Obtain Tcl major version.
    tcl_major_version = tcl_version.split('.')[0]
 
    modules_dirname = f"tcl{tcl_major_version}"
    modules_path = os.path.join(tcl_root, '..', modules_dirname)
 
    if not os.path.isdir(modules_path):
        logger.warning('Tcl modules directory %s does not exist.', modules_path)
        return []
 
    return Tree(modules_path, prefix=modules_dirname)
 
 
def collect_tcl_tk_files(tkinter_ext_file):
    """
    Get a list of TOC-style 3-tuples describing all external Tcl/Tk data files.
 
    Returns
    -------
    Tree
        Such list.
    """
    # Find Tcl and Tk data directory by analyzing the _tkinter extension.
    tcl_root, tk_root = _find_tcl_tk(tkinter_ext_file)
 
    # On macOS, we do not collect system libraries. Therefore, if system Tcl/Tk framework is used, it makes no sense to
    # collect its data, either. In this case, _find_tcl_tk() will return None, None - either deliberately (we found the
    # data paths, but ignore them) or not (starting with macOS 11, the data path cannot be found until shared library
    # discovery is fixed).
    if compat.is_darwin and not tcl_root and not tk_root:
        logger.info(
            "Not collecting Tcl/Tk data - either python is using macOS\' system Tcl/Tk framework, or Tcl/Tk data "
            "directories could not be found."
        )
        return []
 
    # TODO Shouldn't these be fatal exceptions?
    if not tcl_root:
        logger.error('Tcl/Tk improperly installed on this system.')
        return []
    if not os.path.isdir(tcl_root):
        logger.error('Tcl data directory "%s" not found.', tcl_root)
        return []
    if not os.path.isdir(tk_root):
        logger.error('Tk data directory "%s" not found.', tk_root)
        return []
 
    # Collect Tcl and Tk scripts from their corresponding library/data directories. In contrast to source directories,
    # which are typically versioned (tcl8.6, tk8.6), the target directories are unversioned (tcl, tk); they are added
    # to the Tcl/Tk search path via runtime hook for _tkinter, which sets the `TCL_LIBRARY` and `TK_LIBRARY` environment
    # variables.
    tcltree = Tree(tcl_root, prefix=TCL_ROOTNAME, excludes=['demos', '*.lib', 'tclConfig.sh'])
    tktree = Tree(tk_root, prefix=TK_ROOTNAME, excludes=['demos', '*.lib', 'tkConfig.sh'])
 
    # If the current Tcl installation is a Teapot-distributed version of ActiveTcl and the current platform is Mac OS,
    # warn that this is bad.
    if compat.is_darwin:
        _warn_if_activetcl_or_teapot_installed(tcl_root, tcltree)
 
    # Collect Tcl modules.
    tclmodulestree = _collect_tcl_modules(tcl_root)
 
    return tcltree + tktree + tclmodulestree