codekingpro/portable-devtools
115k
1from __future__ import annotations2 3import os4import re5import shlex6import shutil7import struct8import subprocess9import sys10import textwrap11from collections.abc import Iterable12from typing import TYPE_CHECKING, TypedDict13 14from ._importlib import metadata, resources15 16if TYPE_CHECKING:17 from typing_extensions import Self18 19from .warnings import SetuptoolsWarning20 21from distutils.command.build_scripts import first_line_re22from distutils.util import get_platform23 24 25class _SplitArgs(TypedDict, total=False):26 comments: bool27 posix: bool28 29 30class CommandSpec(list):31 """32 A command spec for a #! header, specified as a list of arguments akin to33 those passed to Popen.34 """35 36 options: list[str] = []37 split_args = _SplitArgs()38 39 @classmethod40 def best(cls):41 """42 Choose the best CommandSpec class based on environmental conditions.43 """44 return cls45 46 @classmethod47 def _sys_executable(cls):48 _default = os.path.normpath(sys.executable)49 return os.environ.get('__PYVENV_LAUNCHER__', _default)50 51 @classmethod52 def from_param(cls, param: Self | str | Iterable[str] | None) -> Self:53 """54 Construct a CommandSpec from a parameter to build_scripts, which may55 be None.56 """57 if isinstance(param, cls):58 return param59 if isinstance(param, str):60 return cls.from_string(param)61 if isinstance(param, Iterable):62 return cls(param)63 if param is None:64 return cls.from_environment()65 raise TypeError(f"Argument has an unsupported type {type(param)}")66 67 @classmethod68 def from_environment(cls):69 return cls([cls._sys_executable()])70 71 @classmethod72 def from_string(cls, string: str) -> Self:73 """74 Construct a command spec from a simple string representing a command75 line parseable by shlex.split.76 """77 items = shlex.split(string, **cls.split_args)78 return cls(items)79 80 def install_options(self, script_text: str):81 self.options = shlex.split(self._extract_options(script_text))82 cmdline = subprocess.list2cmdline(self)83 if not isascii(cmdline):84 self.options[:0] = ['-x']85 86 @staticmethod87 def _extract_options(orig_script):88 """89 Extract any options from the first line of the script.90 """91 first = (orig_script + '\n').splitlines()[0]92 match = _first_line_re().match(first)93 options = match.group(1) or '' if match else ''94 return options.strip()95 96 def as_header(self):97 return self._render(self + list(self.options))98 99 @staticmethod100 def _strip_quotes(item):101 _QUOTES = '"\''102 for q in _QUOTES:103 if item.startswith(q) and item.endswith(q):104 return item[1:-1]105 return item106 107 @staticmethod108 def _render(items):109 cmdline = subprocess.list2cmdline(110 CommandSpec._strip_quotes(item.strip()) for item in items111 )112 return '#!' + cmdline + '\n'113 114 115class WindowsCommandSpec(CommandSpec):116 split_args = _SplitArgs(posix=False)117 118 119class ScriptWriter:120 """121 Encapsulates behavior around writing entry point scripts for console and122 gui apps.123 """124 125 template = textwrap.dedent(126 r"""127 # EASY-INSTALL-ENTRY-SCRIPT: %(spec)r,%(group)r,%(name)r128 import re129 import sys130 131 # for compatibility with easy_install; see #2198132 __requires__ = %(spec)r133 134 try:135 from importlib.metadata import distribution136 except ImportError:137 try:138 from importlib_metadata import distribution139 except ImportError:140 from pkg_resources import load_entry_point141 142 143 def importlib_load_entry_point(spec, group, name):144 dist_name, _, _ = spec.partition('==')145 matches = (146 entry_point147 for entry_point in distribution(dist_name).entry_points148 if entry_point.group == group and entry_point.name == name149 )150 return next(matches).load()151 152 153 globals().setdefault('load_entry_point', importlib_load_entry_point)154 155 156 if __name__ == '__main__':157 sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])158 sys.exit(load_entry_point(%(spec)r, %(group)r, %(name)r)())159 """160 ).lstrip()161 162 command_spec_class = CommandSpec163 164 @classmethod165 def get_args(cls, dist, header=None):166 """167 Yield write_script() argument tuples for a distribution's168 console_scripts and gui_scripts entry points.169 """170 171 # If distribution is not an importlib.metadata.Distribution, assume172 # it's a pkg_resources.Distribution and transform it.173 if not hasattr(dist, 'entry_points'):174 SetuptoolsWarning.emit("Unsupported distribution encountered.")175 dist = metadata.Distribution.at(dist.egg_info)176 177 if header is None:178 header = cls.get_header()179 spec = f'{dist.name}=={dist.version}'180 for type_ in 'console', 'gui':181 group = f'{type_}_scripts'182 for ep in dist.entry_points.select(group=group):183 name = ep.name184 cls._ensure_safe_name(ep.name)185 script_text = cls.template % locals()186 args = cls._get_script_args(type_, ep.name, header, script_text)187 yield from args188 189 @staticmethod190 def _ensure_safe_name(name):191 """192 Prevent paths in *_scripts entry point names.193 """194 has_path_sep = re.search(r'[\\/]', name)195 if has_path_sep:196 raise ValueError("Path separators not allowed in script names")197 198 @classmethod199 def best(cls):200 """201 Select the best ScriptWriter for this environment.202 """203 if sys.platform == 'win32' or (os.name == 'java' and os._name == 'nt'):204 return WindowsScriptWriter.best()205 else:206 return cls207 208 @classmethod209 def _get_script_args(cls, type_, name, header, script_text):210 # Simply write the stub with no extension.211 yield (name, header + script_text)212 213 @classmethod214 def get_header(215 cls,216 script_text: str = "",217 executable: str | CommandSpec | Iterable[str] | None = None,218 ) -> str:219 """Create a #! line, getting options (if any) from script_text"""220 cmd = cls.command_spec_class.best().from_param(executable)221 cmd.install_options(script_text)222 return cmd.as_header()223 224 225class WindowsScriptWriter(ScriptWriter):226 command_spec_class = WindowsCommandSpec227 228 @classmethod229 def best(cls):230 """231 Select the best ScriptWriter suitable for Windows232 """233 writer_lookup = dict(234 executable=WindowsExecutableLauncherWriter,235 natural=cls,236 )237 # for compatibility, use the executable launcher by default238 launcher = os.environ.get('SETUPTOOLS_LAUNCHER', 'executable')239 return writer_lookup[launcher]240 241 @classmethod242 def _get_script_args(cls, type_, name, header, script_text):243 "For Windows, add a .py extension"244 ext = dict(console='.pya', gui='.pyw')[type_]245 if ext not in os.environ['PATHEXT'].lower().split(';'):246 msg = (247 "{ext} not listed in PATHEXT; scripts will not be "248 "recognized as executables."249 ).format(**locals())250 SetuptoolsWarning.emit(msg)251 old = ['.pya', '.py', '-script.py', '.pyc', '.pyo', '.pyw', '.exe']252 old.remove(ext)253 header = cls._adjust_header(type_, header)254 blockers = [name + x for x in old]255 yield name + ext, header + script_text, 't', blockers256 257 @classmethod258 def _adjust_header(cls, type_, orig_header):259 """260 Make sure 'pythonw' is used for gui and 'python' is used for261 console (regardless of what sys.executable is).262 """263 pattern = 'pythonw.exe'264 repl = 'python.exe'265 if type_ == 'gui':266 pattern, repl = repl, pattern267 pattern_ob = re.compile(re.escape(pattern), re.IGNORECASE)268 new_header = pattern_ob.sub(string=orig_header, repl=repl)269 return new_header if cls._use_header(new_header) else orig_header270 271 @staticmethod272 def _use_header(new_header):273 """274 Should _adjust_header use the replaced header?275 276 On non-windows systems, always use. On277 Windows systems, only use the replaced header if it resolves278 to an executable on the system.279 """280 clean_header = new_header[2:-1].strip('"')281 return sys.platform != 'win32' or shutil.which(clean_header)282 283 284class WindowsExecutableLauncherWriter(WindowsScriptWriter):285 @classmethod286 def _get_script_args(cls, type_, name, header, script_text):287 """288 For Windows, add a .py extension and an .exe launcher289 """290 if type_ == 'gui':291 launcher_type = 'gui'292 ext = '-script.pyw'293 old = ['.pyw']294 else:295 launcher_type = 'cli'296 ext = '-script.py'297 old = ['.py', '.pyc', '.pyo']298 hdr = cls._adjust_header(type_, header)299 blockers = [name + x for x in old]300 yield (name + ext, hdr + script_text, 't', blockers)301 yield (302 name + '.exe',303 get_win_launcher(launcher_type),304 'b', # write in binary mode305 )306 if not is_64bit():307 # install a manifest for the launcher to prevent Windows308 # from detecting it as an installer (which it will for309 # launchers like easy_install.exe). Consider only310 # adding a manifest for launchers detected as installers.311 # See Distribute #143 for details.312 m_name = name + '.exe.manifest'313 yield (m_name, load_launcher_manifest(name), 't')314 315 316def get_win_launcher(type):317 """318 Load the Windows launcher (executable) suitable for launching a script.319 320 `type` should be either 'cli' or 'gui'321 322 Returns the executable as a byte string.323 """324 launcher_fn = f'{type}.exe'325 if is_64bit():326 if get_platform() == "win-arm64":327 launcher_fn = launcher_fn.replace(".", "-arm64.")328 else:329 launcher_fn = launcher_fn.replace(".", "-64.")330 else:331 launcher_fn = launcher_fn.replace(".", "-32.")332 return resources.files('setuptools').joinpath(launcher_fn).read_bytes()333 334 335def load_launcher_manifest(name):336 res = resources.files(__name__).joinpath('launcher manifest.xml')337 return res.read_text(encoding='utf-8') % vars()338 339 340def _first_line_re():341 """342 Return a regular expression based on first_line_re suitable for matching343 strings.344 """345 if isinstance(first_line_re.pattern, str):346 return first_line_re347 348 # first_line_re in Python >=3.1.4 and >=3.2.1 is a bytes pattern.349 return re.compile(first_line_re.pattern.decode())350 351 352def is_64bit():353 return struct.calcsize("P") == 8354 355 356def isascii(s):357 try:358 s.encode('ascii')359 except UnicodeError:360 return False361 return True362 