_scripts.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. from __future__ import annotations
  2. import os
  3. import re
  4. import shlex
  5. import shutil
  6. import struct
  7. import subprocess
  8. import sys
  9. import textwrap
  10. from collections.abc import Iterable
  11. from typing import TYPE_CHECKING, TypedDict
  12. from ._importlib import metadata, resources
  13. if TYPE_CHECKING:
  14. from typing_extensions import Self
  15. from .warnings import SetuptoolsWarning
  16. from distutils.command.build_scripts import first_line_re
  17. from distutils.util import get_platform
  18. class _SplitArgs(TypedDict, total=False):
  19. comments: bool
  20. posix: bool
  21. class CommandSpec(list):
  22. """
  23. A command spec for a #! header, specified as a list of arguments akin to
  24. those passed to Popen.
  25. """
  26. options: list[str] = []
  27. split_args = _SplitArgs()
  28. @classmethod
  29. def best(cls):
  30. """
  31. Choose the best CommandSpec class based on environmental conditions.
  32. """
  33. return cls
  34. @classmethod
  35. def _sys_executable(cls):
  36. _default = os.path.normpath(sys.executable)
  37. return os.environ.get('__PYVENV_LAUNCHER__', _default)
  38. @classmethod
  39. def from_param(cls, param: Self | str | Iterable[str] | None) -> Self:
  40. """
  41. Construct a CommandSpec from a parameter to build_scripts, which may
  42. be None.
  43. """
  44. if isinstance(param, cls):
  45. return param
  46. if isinstance(param, str):
  47. return cls.from_string(param)
  48. if isinstance(param, Iterable):
  49. return cls(param)
  50. if param is None:
  51. return cls.from_environment()
  52. raise TypeError(f"Argument has an unsupported type {type(param)}")
  53. @classmethod
  54. def from_environment(cls):
  55. return cls([cls._sys_executable()])
  56. @classmethod
  57. def from_string(cls, string: str) -> Self:
  58. """
  59. Construct a command spec from a simple string representing a command
  60. line parseable by shlex.split.
  61. """
  62. items = shlex.split(string, **cls.split_args)
  63. return cls(items)
  64. def install_options(self, script_text: str):
  65. self.options = shlex.split(self._extract_options(script_text))
  66. cmdline = subprocess.list2cmdline(self)
  67. if not isascii(cmdline):
  68. self.options[:0] = ['-x']
  69. @staticmethod
  70. def _extract_options(orig_script):
  71. """
  72. Extract any options from the first line of the script.
  73. """
  74. first = (orig_script + '\n').splitlines()[0]
  75. match = _first_line_re().match(first)
  76. options = match.group(1) or '' if match else ''
  77. return options.strip()
  78. def as_header(self):
  79. return self._render(self + list(self.options))
  80. @staticmethod
  81. def _strip_quotes(item):
  82. _QUOTES = '"\''
  83. for q in _QUOTES:
  84. if item.startswith(q) and item.endswith(q):
  85. return item[1:-1]
  86. return item
  87. @staticmethod
  88. def _render(items):
  89. cmdline = subprocess.list2cmdline(
  90. CommandSpec._strip_quotes(item.strip()) for item in items
  91. )
  92. return '#!' + cmdline + '\n'
  93. class WindowsCommandSpec(CommandSpec):
  94. split_args = _SplitArgs(posix=False)
  95. class ScriptWriter:
  96. """
  97. Encapsulates behavior around writing entry point scripts for console and
  98. gui apps.
  99. """
  100. template = textwrap.dedent(
  101. r"""
  102. # EASY-INSTALL-ENTRY-SCRIPT: %(spec)r,%(group)r,%(name)r
  103. import re
  104. import sys
  105. # for compatibility with easy_install; see #2198
  106. __requires__ = %(spec)r
  107. try:
  108. from importlib.metadata import distribution
  109. except ImportError:
  110. try:
  111. from importlib_metadata import distribution
  112. except ImportError:
  113. from pkg_resources import load_entry_point
  114. def importlib_load_entry_point(spec, group, name):
  115. dist_name, _, _ = spec.partition('==')
  116. matches = (
  117. entry_point
  118. for entry_point in distribution(dist_name).entry_points
  119. if entry_point.group == group and entry_point.name == name
  120. )
  121. return next(matches).load()
  122. globals().setdefault('load_entry_point', importlib_load_entry_point)
  123. if __name__ == '__main__':
  124. sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
  125. sys.exit(load_entry_point(%(spec)r, %(group)r, %(name)r)())
  126. """
  127. ).lstrip()
  128. command_spec_class = CommandSpec
  129. @classmethod
  130. def get_args(cls, dist, header=None):
  131. """
  132. Yield write_script() argument tuples for a distribution's
  133. console_scripts and gui_scripts entry points.
  134. """
  135. # If distribution is not an importlib.metadata.Distribution, assume
  136. # it's a pkg_resources.Distribution and transform it.
  137. if not hasattr(dist, 'entry_points'):
  138. SetuptoolsWarning.emit("Unsupported distribution encountered.")
  139. dist = metadata.Distribution.at(dist.egg_info)
  140. if header is None:
  141. header = cls.get_header()
  142. spec = f'{dist.name}=={dist.version}'
  143. for type_ in 'console', 'gui':
  144. group = f'{type_}_scripts'
  145. for ep in dist.entry_points.select(group=group):
  146. name = ep.name
  147. cls._ensure_safe_name(ep.name)
  148. script_text = cls.template % locals()
  149. args = cls._get_script_args(type_, ep.name, header, script_text)
  150. yield from args
  151. @staticmethod
  152. def _ensure_safe_name(name):
  153. """
  154. Prevent paths in *_scripts entry point names.
  155. """
  156. has_path_sep = re.search(r'[\\/]', name)
  157. if has_path_sep:
  158. raise ValueError("Path separators not allowed in script names")
  159. @classmethod
  160. def best(cls):
  161. """
  162. Select the best ScriptWriter for this environment.
  163. """
  164. if sys.platform == 'win32' or (os.name == 'java' and os._name == 'nt'):
  165. return WindowsScriptWriter.best()
  166. else:
  167. return cls
  168. @classmethod
  169. def _get_script_args(cls, type_, name, header, script_text):
  170. # Simply write the stub with no extension.
  171. yield (name, header + script_text)
  172. @classmethod
  173. def get_header(
  174. cls,
  175. script_text: str = "",
  176. executable: str | CommandSpec | Iterable[str] | None = None,
  177. ) -> str:
  178. """Create a #! line, getting options (if any) from script_text"""
  179. cmd = cls.command_spec_class.best().from_param(executable)
  180. cmd.install_options(script_text)
  181. return cmd.as_header()
  182. class WindowsScriptWriter(ScriptWriter):
  183. command_spec_class = WindowsCommandSpec
  184. @classmethod
  185. def best(cls):
  186. """
  187. Select the best ScriptWriter suitable for Windows
  188. """
  189. writer_lookup = dict(
  190. executable=WindowsExecutableLauncherWriter,
  191. natural=cls,
  192. )
  193. # for compatibility, use the executable launcher by default
  194. launcher = os.environ.get('SETUPTOOLS_LAUNCHER', 'executable')
  195. return writer_lookup[launcher]
  196. @classmethod
  197. def _get_script_args(cls, type_, name, header, script_text):
  198. "For Windows, add a .py extension"
  199. ext = dict(console='.pya', gui='.pyw')[type_]
  200. if ext not in os.environ['PATHEXT'].lower().split(';'):
  201. msg = (
  202. "{ext} not listed in PATHEXT; scripts will not be "
  203. "recognized as executables."
  204. ).format(**locals())
  205. SetuptoolsWarning.emit(msg)
  206. old = ['.pya', '.py', '-script.py', '.pyc', '.pyo', '.pyw', '.exe']
  207. old.remove(ext)
  208. header = cls._adjust_header(type_, header)
  209. blockers = [name + x for x in old]
  210. yield name + ext, header + script_text, 't', blockers
  211. @classmethod
  212. def _adjust_header(cls, type_, orig_header):
  213. """
  214. Make sure 'pythonw' is used for gui and 'python' is used for
  215. console (regardless of what sys.executable is).
  216. """
  217. pattern = 'pythonw.exe'
  218. repl = 'python.exe'
  219. if type_ == 'gui':
  220. pattern, repl = repl, pattern
  221. pattern_ob = re.compile(re.escape(pattern), re.IGNORECASE)
  222. new_header = pattern_ob.sub(string=orig_header, repl=repl)
  223. return new_header if cls._use_header(new_header) else orig_header
  224. @staticmethod
  225. def _use_header(new_header):
  226. """
  227. Should _adjust_header use the replaced header?
  228. On non-windows systems, always use. On
  229. Windows systems, only use the replaced header if it resolves
  230. to an executable on the system.
  231. """
  232. clean_header = new_header[2:-1].strip('"')
  233. return sys.platform != 'win32' or shutil.which(clean_header)
  234. class WindowsExecutableLauncherWriter(WindowsScriptWriter):
  235. @classmethod
  236. def _get_script_args(cls, type_, name, header, script_text):
  237. """
  238. For Windows, add a .py extension and an .exe launcher
  239. """
  240. if type_ == 'gui':
  241. launcher_type = 'gui'
  242. ext = '-script.pyw'
  243. old = ['.pyw']
  244. else:
  245. launcher_type = 'cli'
  246. ext = '-script.py'
  247. old = ['.py', '.pyc', '.pyo']
  248. hdr = cls._adjust_header(type_, header)
  249. blockers = [name + x for x in old]
  250. yield (name + ext, hdr + script_text, 't', blockers)
  251. yield (
  252. name + '.exe',
  253. get_win_launcher(launcher_type),
  254. 'b', # write in binary mode
  255. )
  256. if not is_64bit():
  257. # install a manifest for the launcher to prevent Windows
  258. # from detecting it as an installer (which it will for
  259. # launchers like easy_install.exe). Consider only
  260. # adding a manifest for launchers detected as installers.
  261. # See Distribute #143 for details.
  262. m_name = name + '.exe.manifest'
  263. yield (m_name, load_launcher_manifest(name), 't')
  264. def get_win_launcher(type):
  265. """
  266. Load the Windows launcher (executable) suitable for launching a script.
  267. `type` should be either 'cli' or 'gui'
  268. Returns the executable as a byte string.
  269. """
  270. launcher_fn = f'{type}.exe'
  271. if is_64bit():
  272. if get_platform() == "win-arm64":
  273. launcher_fn = launcher_fn.replace(".", "-arm64.")
  274. else:
  275. launcher_fn = launcher_fn.replace(".", "-64.")
  276. else:
  277. launcher_fn = launcher_fn.replace(".", "-32.")
  278. return resources.files('setuptools').joinpath(launcher_fn).read_bytes()
  279. def load_launcher_manifest(name):
  280. res = resources.files(__name__).joinpath('launcher manifest.xml')
  281. return res.read_text(encoding='utf-8') % vars()
  282. def _first_line_re():
  283. """
  284. Return a regular expression based on first_line_re suitable for matching
  285. strings.
  286. """
  287. if isinstance(first_line_re.pattern, str):
  288. return first_line_re
  289. # first_line_re in Python >=3.1.4 and >=3.2.1 is a bytes pattern.
  290. return re.compile(first_line_re.pattern.decode())
  291. def is_64bit():
  292. return struct.calcsize("P") == 8
  293. def isascii(s):
  294. try:
  295. s.encode('ascii')
  296. except UnicodeError:
  297. return False
  298. return True