|
|
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 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): |
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
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', |
|
|
) |
|
|
if not is_64bit(): |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|