pyi_splash.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. # -----------------------------------------------------------------------------
  2. # Copyright (c) 2005-2022, 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. # This module is not a "fake module" in the classical sense, but a real module that can be imported. It acts as an RPC
  12. # interface for the functions of the bootloader.
  13. """
  14. This module connects to the bootloader to send messages to the splash screen.
  15. It is intended to act as a RPC interface for the functions provided by the bootloader, such as displaying text or
  16. closing. This makes the users python program independent of how the communication with the bootloader is implemented,
  17. since a consistent API is provided.
  18. To connect to the bootloader, it connects to a local tcp socket whose port is passed through the environment variable
  19. '_PYIBoot_SPLASH'. The bootloader creates a server socket and accepts every connection request. Since the os-module,
  20. which is needed to request the environment variable, is not available at boot time, the module does not establish the
  21. connection until initialization.
  22. The protocol by which the Python interpreter communicates with the bootloader is implemented in this module.
  23. This module does not support reloads while the splash screen is displayed, i.e. it cannot be reloaded (such as by
  24. importlib.reload), because the splash screen closes automatically when the connection to this instance of the module
  25. is lost.
  26. """
  27. import atexit
  28. import os
  29. # Import the _socket module instead of the socket module. All used functions to connect to the ipc system are
  30. # provided by the C module and the users program does not necessarily need to include the socket module and all
  31. # required modules it uses.
  32. import _socket
  33. __all__ = ["CLOSE_CONNECTION", "FLUSH_CHARACTER", "is_alive", "close", "update_text"]
  34. try:
  35. # The user might have excluded logging from imports.
  36. import logging as _logging
  37. except ImportError:
  38. _logging = None
  39. try:
  40. # The user might have excluded functools from imports.
  41. from functools import update_wrapper
  42. except ImportError:
  43. update_wrapper = None
  44. # Utility
  45. def _log(level, msg, *args, **kwargs):
  46. """
  47. Conditional wrapper around logging module. If the user excluded logging from the imports or it was not imported,
  48. this function should handle it and avoid using the logger.
  49. """
  50. if _logging:
  51. logger = _logging.getLogger(__name__)
  52. logger.log(level, msg, *args, **kwargs)
  53. # These constants define single characters which are needed to send commands to the bootloader. Those constants are
  54. # also set in the tcl script.
  55. CLOSE_CONNECTION = b'\x04' # ASCII End-of-Transmission character
  56. FLUSH_CHARACTER = b'\x0D' # ASCII Carriage Return character
  57. # Module internal variables
  58. _initialized = False
  59. # Keep these variables always synchronized
  60. _ipc_socket_closed = True
  61. _ipc_socket = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
  62. def _initialize():
  63. """
  64. Initialize this module
  65. :return:
  66. """
  67. global _initialized, _ipc_socket, _ipc_socket_closed
  68. try:
  69. _ipc_socket.connect(("localhost", _ipc_port))
  70. _ipc_socket_closed = False
  71. _initialized = True
  72. _log(20, "A connection to the splash screen was successfully established.") # log-level: info
  73. except OSError as err:
  74. raise ConnectionError("Unable to connect to the tcp server socket on port %d" % _ipc_port) from err
  75. # We expect a splash screen from the bootloader, but if _PYIBoot_SPLASH is not set, the module cannot connect to it.
  76. try:
  77. _ipc_port = int(os.environ['_PYIBoot_SPLASH'])
  78. del os.environ['_PYIBoot_SPLASH']
  79. # Initialize the connection upon importing this module. This will establish a connection to the bootloader's TCP
  80. # server socket.
  81. _initialize()
  82. except (KeyError, ValueError) as _err:
  83. # log-level: warning
  84. _log(
  85. 30, "The environment does not allow connecting to the splash screen. Are the splash resources attached to the "
  86. "bootloader or did an error occur?",
  87. exc_info=_err
  88. )
  89. except ConnectionError as _err:
  90. # log-level: error
  91. _log(40, "Cannot connect to the bootloaders ipc server socket", exc_info=_err)
  92. def _check_connection(func):
  93. """
  94. Utility decorator for checking whether the function should be executed.
  95. The wrapped function may raise a ConnectionError if the module was not initialized correctly.
  96. """
  97. def wrapper(*args, **kwargs):
  98. """
  99. Executes the wrapped function if the environment allows it.
  100. That is, if the connection to to bootloader has not been closed and the module is initialized.
  101. :raises RuntimeError: if the module was not initialized correctly.
  102. """
  103. if _initialized and _ipc_socket_closed:
  104. _log(
  105. 20, "The module has been disabled, so the use of the splash screen is no longer supported."
  106. ) # log-level: info
  107. return
  108. elif not _initialized:
  109. raise RuntimeError("This module is not initialized; did it fail to load?")
  110. return func(*args, **kwargs)
  111. if update_wrapper:
  112. # For runtime introspection
  113. update_wrapper(wrapper, func)
  114. return wrapper
  115. @_check_connection
  116. def _send_command(cmd, args=None):
  117. """
  118. Send the command followed by args to the splash screen.
  119. :param str cmd: The command to send. All command have to be defined as procedures in the tcl splash screen script.
  120. :param list[str] args: All arguments to send to the receiving function
  121. """
  122. if args is None:
  123. args = []
  124. full_cmd = "%s(%s)" % (cmd, " ".join(args))
  125. try:
  126. _ipc_socket.sendall(full_cmd.encode("utf-8") + FLUSH_CHARACTER)
  127. except OSError as err:
  128. raise ConnectionError("Unable to send '%s' to the bootloader" % full_cmd) from err
  129. def is_alive():
  130. """
  131. Indicates whether the module can be used.
  132. Returns False if the module is either not initialized or was disabled by closing the splash screen. Otherwise,
  133. the module should be usable.
  134. """
  135. return _initialized and not _ipc_socket_closed
  136. @_check_connection
  137. def update_text(msg):
  138. """
  139. Updates the text on the splash screen window.
  140. :param str msg: the text to be displayed
  141. :raises ConnectionError: If the OS fails to write to the socket.
  142. :raises RuntimeError: If the module is not initialized.
  143. """
  144. _send_command("update_text", [msg])
  145. def close():
  146. """
  147. Close the connection to the ipc tcp server socket.
  148. This will close the splash screen and renders this module unusable. After this function is called, no connection
  149. can be opened to the splash screen again and all functions in this module become unusable.
  150. """
  151. global _ipc_socket_closed
  152. if _initialized and not _ipc_socket_closed:
  153. _ipc_socket.sendall(CLOSE_CONNECTION)
  154. _ipc_socket.close()
  155. _ipc_socket_closed = True
  156. @atexit.register
  157. def _exit():
  158. close()
  159. # Discarded idea:
  160. # Problem:
  161. # There was a race condition between the tcl (splash screen) and python interpreter. Initially the tcl was started as a
  162. # separate thread next to the bootloader thread, which starts python. Tcl sets the environment variable
  163. # '_PYIBoot_SPLASH' with a port to connect to. If the python interpreter is faster initialized than the tcl interpreter
  164. # (sometimes the case in onedir mode) the environment variable does not yet exist. Since python caches the environment
  165. # variables at startup, updating the environ from tcl does not update the python environ.
  166. #
  167. # Considered Solution:
  168. # Dont rely on python itself to look up the environment variables. We could implement via ctypes functions to look up
  169. # the latest environ. See https://stackoverflow.com/a/33642551/5869139 for a possible implementation.
  170. #
  171. # Discarded because:
  172. # This module would need to implement for every supported OS a dll hook to link to the environ variable, technically
  173. # reimplementing the C function 'convertenviron' from posixmodule.c_ in python. The implemented solution now waits for
  174. # the tcl interpreter to finish before starting python.
  175. #
  176. # .. _posixmodule.c:
  177. # https://github.com/python/cpython/blob/3.7/Modules/posixmodule.c#L1315-L1393