361 lines
11 KiB
Python
361 lines
11 KiB
Python
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
|