| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361 |
- from __future__ import annotations
- import os
- import re
- import shlex
- import shutil
- import struct
- import subprocess
- import sys
- import textwrap
- from collections.abc import Iterable
- from typing import TYPE_CHECKING, TypedDict
- from ._importlib import metadata, resources
- if TYPE_CHECKING:
- from typing_extensions import Self
- from .warnings import SetuptoolsWarning
- from distutils.command.build_scripts import first_line_re
- from distutils.util import get_platform
- class _SplitArgs(TypedDict, total=False):
- comments: bool
- posix: bool
- class CommandSpec(list):
- """
- A command spec for a #! header, specified as a list of arguments akin to
- those passed to Popen.
- """
- options: list[str] = []
- split_args = _SplitArgs()
- @classmethod
- def best(cls):
- """
- Choose the best CommandSpec class based on environmental conditions.
- """
- return cls
- @classmethod
- def _sys_executable(cls):
- _default = os.path.normpath(sys.executable)
- return os.environ.get('__PYVENV_LAUNCHER__', _default)
- @classmethod
- def from_param(cls, param: Self | str | Iterable[str] | None) -> Self:
- """
- Construct a CommandSpec from a parameter to build_scripts, which may
- be None.
- """
- if isinstance(param, cls):
- return param
- if isinstance(param, str):
- return cls.from_string(param)
- if isinstance(param, Iterable):
- return cls(param)
- if param is None:
- return cls.from_environment()
- raise TypeError(f"Argument has an unsupported type {type(param)}")
- @classmethod
- def from_environment(cls):
- return cls([cls._sys_executable()])
- @classmethod
- def from_string(cls, string: str) -> Self:
- """
- Construct a command spec from a simple string representing a command
- line parseable by shlex.split.
- """
- items = shlex.split(string, **cls.split_args)
- return cls(items)
- def install_options(self, script_text: str):
- self.options = shlex.split(self._extract_options(script_text))
- cmdline = subprocess.list2cmdline(self)
- if not isascii(cmdline):
- self.options[:0] = ['-x']
- @staticmethod
- def _extract_options(orig_script):
- """
- Extract any options from the first line of the script.
- """
- first = (orig_script + '\n').splitlines()[0]
- match = _first_line_re().match(first)
- options = match.group(1) or '' if match else ''
- return options.strip()
- def as_header(self):
- return self._render(self + list(self.options))
- @staticmethod
- def _strip_quotes(item):
- _QUOTES = '"\''
- for q in _QUOTES:
- if item.startswith(q) and item.endswith(q):
- return item[1:-1]
- return item
- @staticmethod
- def _render(items):
- cmdline = subprocess.list2cmdline(
- CommandSpec._strip_quotes(item.strip()) for item in items
- )
- return '#!' + cmdline + '\n'
- class WindowsCommandSpec(CommandSpec):
- split_args = _SplitArgs(posix=False)
- class ScriptWriter:
- """
- Encapsulates behavior around writing entry point scripts for console and
- gui apps.
- """
- template = textwrap.dedent(
- r"""
- # EASY-INSTALL-ENTRY-SCRIPT: %(spec)r,%(group)r,%(name)r
- import re
- import sys
- # for compatibility with easy_install; see #2198
- __requires__ = %(spec)r
- try:
- from importlib.metadata import distribution
- except ImportError:
- try:
- from importlib_metadata import distribution
- except ImportError:
- from pkg_resources import load_entry_point
- def importlib_load_entry_point(spec, group, name):
- dist_name, _, _ = spec.partition('==')
- matches = (
- entry_point
- for entry_point in distribution(dist_name).entry_points
- if entry_point.group == group and entry_point.name == name
- )
- return next(matches).load()
- globals().setdefault('load_entry_point', importlib_load_entry_point)
- if __name__ == '__main__':
- sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
- sys.exit(load_entry_point(%(spec)r, %(group)r, %(name)r)())
- """
- ).lstrip()
- command_spec_class = CommandSpec
- @classmethod
- def get_args(cls, dist, header=None):
- """
- Yield write_script() argument tuples for a distribution's
- console_scripts and gui_scripts entry points.
- """
- # If distribution is not an importlib.metadata.Distribution, assume
- # it's a pkg_resources.Distribution and transform it.
- if not hasattr(dist, 'entry_points'):
- SetuptoolsWarning.emit("Unsupported distribution encountered.")
- dist = metadata.Distribution.at(dist.egg_info)
- if header is None:
- header = cls.get_header()
- spec = f'{dist.name}=={dist.version}'
- for type_ in 'console', 'gui':
- group = f'{type_}_scripts'
- for ep in dist.entry_points.select(group=group):
- name = ep.name
- cls._ensure_safe_name(ep.name)
- script_text = cls.template % locals()
- args = cls._get_script_args(type_, ep.name, header, script_text)
- yield from args
- @staticmethod
- def _ensure_safe_name(name):
- """
- Prevent paths in *_scripts entry point names.
- """
- has_path_sep = re.search(r'[\\/]', name)
- if has_path_sep:
- raise ValueError("Path separators not allowed in script names")
- @classmethod
- def best(cls):
- """
- Select the best ScriptWriter for this environment.
- """
- if sys.platform == 'win32' or (os.name == 'java' and os._name == 'nt'):
- return WindowsScriptWriter.best()
- else:
- return cls
- @classmethod
- def _get_script_args(cls, type_, name, header, script_text):
- # Simply write the stub with no extension.
- yield (name, header + script_text)
- @classmethod
- def get_header(
- cls,
- script_text: str = "",
- executable: str | CommandSpec | Iterable[str] | None = None,
- ) -> str:
- """Create a #! line, getting options (if any) from script_text"""
- cmd = cls.command_spec_class.best().from_param(executable)
- cmd.install_options(script_text)
- return cmd.as_header()
- class WindowsScriptWriter(ScriptWriter):
- command_spec_class = WindowsCommandSpec
- @classmethod
- def best(cls):
- """
- Select the best ScriptWriter suitable for Windows
- """
- writer_lookup = dict(
- executable=WindowsExecutableLauncherWriter,
- natural=cls,
- )
- # for compatibility, use the executable launcher by default
- launcher = os.environ.get('SETUPTOOLS_LAUNCHER', 'executable')
- return writer_lookup[launcher]
- @classmethod
- def _get_script_args(cls, type_, name, header, script_text):
- "For Windows, add a .py extension"
- ext = dict(console='.pya', gui='.pyw')[type_]
- if ext not in os.environ['PATHEXT'].lower().split(';'):
- msg = (
- "{ext} not listed in PATHEXT; scripts will not be "
- "recognized as executables."
- ).format(**locals())
- SetuptoolsWarning.emit(msg)
- old = ['.pya', '.py', '-script.py', '.pyc', '.pyo', '.pyw', '.exe']
- old.remove(ext)
- header = cls._adjust_header(type_, header)
- blockers = [name + x for x in old]
- yield name + ext, header + script_text, 't', blockers
- @classmethod
- def _adjust_header(cls, type_, orig_header):
- """
- Make sure 'pythonw' is used for gui and 'python' is used for
- console (regardless of what sys.executable is).
- """
- pattern = 'pythonw.exe'
- repl = 'python.exe'
- if type_ == 'gui':
- pattern, repl = repl, pattern
- pattern_ob = re.compile(re.escape(pattern), re.IGNORECASE)
- new_header = pattern_ob.sub(string=orig_header, repl=repl)
- return new_header if cls._use_header(new_header) else orig_header
- @staticmethod
- def _use_header(new_header):
- """
- Should _adjust_header use the replaced header?
- On non-windows systems, always use. On
- Windows systems, only use the replaced header if it resolves
- to an executable on the system.
- """
- clean_header = new_header[2:-1].strip('"')
- return sys.platform != 'win32' or shutil.which(clean_header)
- class WindowsExecutableLauncherWriter(WindowsScriptWriter):
- @classmethod
- def _get_script_args(cls, type_, name, header, script_text):
- """
- For Windows, add a .py extension and an .exe launcher
- """
- if type_ == 'gui':
- launcher_type = 'gui'
- ext = '-script.pyw'
- old = ['.pyw']
- else:
- launcher_type = 'cli'
- ext = '-script.py'
- old = ['.py', '.pyc', '.pyo']
- hdr = cls._adjust_header(type_, header)
- blockers = [name + x for x in old]
- yield (name + ext, hdr + script_text, 't', blockers)
- yield (
- name + '.exe',
- get_win_launcher(launcher_type),
- 'b', # write in binary mode
- )
- if not is_64bit():
- # install a manifest for the launcher to prevent Windows
- # from detecting it as an installer (which it will for
- # launchers like easy_install.exe). Consider only
- # adding a manifest for launchers detected as installers.
- # See Distribute #143 for details.
- m_name = name + '.exe.manifest'
- yield (m_name, load_launcher_manifest(name), 't')
- def get_win_launcher(type):
- """
- Load the Windows launcher (executable) suitable for launching a script.
- `type` should be either 'cli' or 'gui'
- Returns the executable as a byte string.
- """
- launcher_fn = f'{type}.exe'
- if is_64bit():
- if get_platform() == "win-arm64":
- launcher_fn = launcher_fn.replace(".", "-arm64.")
- else:
- launcher_fn = launcher_fn.replace(".", "-64.")
- else:
- launcher_fn = launcher_fn.replace(".", "-32.")
- return resources.files('setuptools').joinpath(launcher_fn).read_bytes()
- def load_launcher_manifest(name):
- res = resources.files(__name__).joinpath('launcher manifest.xml')
- return res.read_text(encoding='utf-8') % vars()
- def _first_line_re():
- """
- Return a regular expression based on first_line_re suitable for matching
- strings.
- """
- if isinstance(first_line_re.pattern, str):
- return first_line_re
- # first_line_re in Python >=3.1.4 and >=3.2.1 is a bytes pattern.
- return re.compile(first_line_re.pattern.decode())
- def is_64bit():
- return struct.calcsize("P") == 8
- def isascii(s):
- try:
- s.encode('ascii')
- except UnicodeError:
- return False
- return True
|