codekingpro/portable-devtools
114k
1# -*- coding: utf-8 -*-2#3# Copyright (C) 2013-2023 Vinay Sajip.4# Licensed to the Python Software Foundation under a contributor agreement.5# See LICENSE.txt and CONTRIBUTORS.txt.6#7from io import BytesIO8import logging9import os10import re11import struct12import sys13import time14from zipfile import ZipInfo15 16from .compat import sysconfig, detect_encoding, ZipFile17from .resources import finder18from .util import (FileOperator, get_export_entry, convert_path,19 get_executable, get_platform, in_venv)20 21logger = logging.getLogger(__name__)22 23_DEFAULT_MANIFEST = '''24<?xml version="1.0" encoding="UTF-8" standalone="yes"?>25<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">26 <assemblyIdentity version="1.0.0.0"27 processorArchitecture="X86"28 name="%s"29 type="win32"/>30 31 <!-- Identify the application security requirements. -->32 <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">33 <security>34 <requestedPrivileges>35 <requestedExecutionLevel level="asInvoker" uiAccess="false"/>36 </requestedPrivileges>37 </security>38 </trustInfo>39</assembly>'''.strip()40 41# check if Python is called on the first line with this expression42FIRST_LINE_RE = re.compile(b'^#!.*pythonw?[0-9.]*([ \t].*)?$')43SCRIPT_TEMPLATE = r'''# -*- coding: utf-8 -*-44import re45import sys46from %(module)s import %(import_name)s47if __name__ == '__main__':48 sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])49 sys.exit(%(func)s())50'''51 52 53def enquote_executable(executable):54 if ' ' in executable:55 # make sure we quote only the executable in case of env56 # for example /usr/bin/env "/dir with spaces/bin/jython"57 # instead of "/usr/bin/env /dir with spaces/bin/jython"58 # otherwise whole59 if executable.startswith('/usr/bin/env '):60 env, _executable = executable.split(' ', 1)61 if ' ' in _executable and not _executable.startswith('"'):62 executable = '%s "%s"' % (env, _executable)63 else:64 if not executable.startswith('"'):65 executable = '"%s"' % executable66 return executable67 68 69# Keep the old name around (for now), as there is at least one project using it!70_enquote_executable = enquote_executable71 72 73class ScriptMaker(object):74 """75 A class to copy or create scripts from source scripts or callable76 specifications.77 """78 script_template = SCRIPT_TEMPLATE79 80 executable = None # for shebangs81 82 def __init__(self,83 source_dir,84 target_dir,85 add_launchers=True,86 dry_run=False,87 fileop=None):88 self.source_dir = source_dir89 self.target_dir = target_dir90 self.add_launchers = add_launchers91 self.force = False92 self.clobber = False93 # It only makes sense to set mode bits on POSIX.94 self.set_mode = (os.name == 'posix') or (os.name == 'java'95 and os._name == 'posix')96 self.variants = set(('', 'X.Y'))97 self._fileop = fileop or FileOperator(dry_run)98 99 self._is_nt = os.name == 'nt' or (os.name == 'java'100 and os._name == 'nt')101 self.version_info = sys.version_info102 103 def _get_alternate_executable(self, executable, options):104 if options.get('gui', False) and self._is_nt: # pragma: no cover105 dn, fn = os.path.split(executable)106 fn = fn.replace('python', 'pythonw')107 executable = os.path.join(dn, fn)108 return executable109 110 if sys.platform.startswith('java'): # pragma: no cover111 112 def _is_shell(self, executable):113 """114 Determine if the specified executable is a script115 (contains a #! line)116 """117 try:118 with open(executable) as fp:119 return fp.read(2) == '#!'120 except (OSError, IOError):121 logger.warning('Failed to open %s', executable)122 return False123 124 def _fix_jython_executable(self, executable):125 if self._is_shell(executable):126 # Workaround for Jython is not needed on Linux systems.127 import java128 129 if java.lang.System.getProperty('os.name') == 'Linux':130 return executable131 elif executable.lower().endswith('jython.exe'):132 # Use wrapper exe for Jython on Windows133 return executable134 return '/usr/bin/env %s' % executable135 136 def _build_shebang(self, executable, post_interp):137 """138 Build a shebang line. In the simple case (on Windows, or a shebang line139 which is not too long or contains spaces) use a simple formulation for140 the shebang. Otherwise, use /bin/sh as the executable, with a contrived141 shebang which allows the script to run either under Python or sh, using142 suitable quoting. Thanks to Harald Nordgren for his input.143 144 See also: http://www.in-ulm.de/~mascheck/various/shebang/#length145 https://hg.mozilla.org/mozilla-central/file/tip/mach146 """147 if os.name != 'posix':148 simple_shebang = True149 else:150 # Add 3 for '#!' prefix and newline suffix.151 shebang_length = len(executable) + len(post_interp) + 3152 if sys.platform == 'darwin':153 max_shebang_length = 512154 else:155 max_shebang_length = 127156 simple_shebang = ((b' ' not in executable)157 and (shebang_length <= max_shebang_length))158 159 if simple_shebang:160 result = b'#!' + executable + post_interp + b'\n'161 else:162 result = b'#!/bin/sh\n'163 result += b"'''exec' " + executable + post_interp + b' "$0" "$@"\n'164 result += b"' '''"165 return result166 167 def _get_shebang(self, encoding, post_interp=b'', options=None):168 enquote = True169 if self.executable:170 executable = self.executable171 enquote = False # assume this will be taken care of172 elif not sysconfig.is_python_build():173 executable = get_executable()174 elif in_venv(): # pragma: no cover175 executable = os.path.join(176 sysconfig.get_path('scripts'),177 'python%s' % sysconfig.get_config_var('EXE'))178 else: # pragma: no cover179 if os.name == 'nt':180 # for Python builds from source on Windows, no Python executables with181 # a version suffix are created, so we use python.exe182 executable = os.path.join(183 sysconfig.get_config_var('BINDIR'),184 'python%s' % (sysconfig.get_config_var('EXE')))185 else:186 executable = os.path.join(187 sysconfig.get_config_var('BINDIR'),188 'python%s%s' % (sysconfig.get_config_var('VERSION'),189 sysconfig.get_config_var('EXE')))190 if options:191 executable = self._get_alternate_executable(executable, options)192 193 if sys.platform.startswith('java'): # pragma: no cover194 executable = self._fix_jython_executable(executable)195 196 # Normalise case for Windows - COMMENTED OUT197 # executable = os.path.normcase(executable)198 # N.B. The normalising operation above has been commented out: See199 # issue #124. Although paths in Windows are generally case-insensitive,200 # they aren't always. For example, a path containing a ẞ (which is a201 # LATIN CAPITAL LETTER SHARP S - U+1E9E) is normcased to ß (which is a202 # LATIN SMALL LETTER SHARP S' - U+00DF). The two are not considered by203 # Windows as equivalent in path names.204 205 # If the user didn't specify an executable, it may be necessary to206 # cater for executable paths with spaces (not uncommon on Windows)207 if enquote:208 executable = enquote_executable(executable)209 # Issue #51: don't use fsencode, since we later try to210 # check that the shebang is decodable using utf-8.211 executable = executable.encode('utf-8')212 # in case of IronPython, play safe and enable frames support213 if (sys.platform == 'cli' and '-X:Frames' not in post_interp214 and '-X:FullFrames' not in post_interp): # pragma: no cover215 post_interp += b' -X:Frames'216 shebang = self._build_shebang(executable, post_interp)217 # Python parser starts to read a script using UTF-8 until218 # it gets a #coding:xxx cookie. The shebang has to be the219 # first line of a file, the #coding:xxx cookie cannot be220 # written before. So the shebang has to be decodable from221 # UTF-8.222 try:223 shebang.decode('utf-8')224 except UnicodeDecodeError: # pragma: no cover225 raise ValueError('The shebang (%r) is not decodable from utf-8' %226 shebang)227 # If the script is encoded to a custom encoding (use a228 # #coding:xxx cookie), the shebang has to be decodable from229 # the script encoding too.230 if encoding != 'utf-8':231 try:232 shebang.decode(encoding)233 except UnicodeDecodeError: # pragma: no cover234 raise ValueError('The shebang (%r) is not decodable '235 'from the script encoding (%r)' %236 (shebang, encoding))237 return shebang238 239 def _get_script_text(self, entry):240 return self.script_template % dict(241 module=entry.prefix,242 import_name=entry.suffix.split('.')[0],243 func=entry.suffix)244 245 manifest = _DEFAULT_MANIFEST246 247 def get_manifest(self, exename):248 base = os.path.basename(exename)249 return self.manifest % base250 251 def _write_script(self, names, shebang, script_bytes, filenames, ext):252 use_launcher = self.add_launchers and self._is_nt253 linesep = os.linesep.encode('utf-8')254 if not shebang.endswith(linesep):255 shebang += linesep256 if not use_launcher:257 script_bytes = shebang + script_bytes258 else: # pragma: no cover259 if ext == 'py':260 launcher = self._get_launcher('t')261 else:262 launcher = self._get_launcher('w')263 stream = BytesIO()264 with ZipFile(stream, 'w') as zf:265 source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH')266 if source_date_epoch:267 date_time = time.gmtime(int(source_date_epoch))[:6]268 zinfo = ZipInfo(filename='__main__.py',269 date_time=date_time)270 zf.writestr(zinfo, script_bytes)271 else:272 zf.writestr('__main__.py', script_bytes)273 zip_data = stream.getvalue()274 script_bytes = launcher + shebang + zip_data275 for name in names:276 outname = os.path.join(self.target_dir, name)277 if use_launcher: # pragma: no cover278 n, e = os.path.splitext(outname)279 if e.startswith('.py'):280 outname = n281 outname = '%s.exe' % outname282 try:283 self._fileop.write_binary_file(outname, script_bytes)284 except Exception:285 # Failed writing an executable - it might be in use.286 logger.warning('Failed to write executable - trying to '287 'use .deleteme logic')288 dfname = '%s.deleteme' % outname289 if os.path.exists(dfname):290 os.remove(dfname) # Not allowed to fail here291 os.rename(outname, dfname) # nor here292 self._fileop.write_binary_file(outname, script_bytes)293 logger.debug('Able to replace executable using '294 '.deleteme logic')295 try:296 os.remove(dfname)297 except Exception:298 pass # still in use - ignore error299 else:300 if self._is_nt and not outname.endswith(301 '.' + ext): # pragma: no cover302 outname = '%s.%s' % (outname, ext)303 if os.path.exists(outname) and not self.clobber:304 logger.warning('Skipping existing file %s', outname)305 continue306 self._fileop.write_binary_file(outname, script_bytes)307 if self.set_mode:308 self._fileop.set_executable_mode([outname])309 filenames.append(outname)310 311 variant_separator = '-'312 313 def get_script_filenames(self, name):314 result = set()315 if '' in self.variants:316 result.add(name)317 if 'X' in self.variants:318 result.add('%s%s' % (name, self.version_info[0]))319 if 'X.Y' in self.variants:320 result.add('%s%s%s.%s' %321 (name, self.variant_separator, self.version_info[0],322 self.version_info[1]))323 return result324 325 def _make_script(self, entry, filenames, options=None):326 post_interp = b''327 if options:328 args = options.get('interpreter_args', [])329 if args:330 args = ' %s' % ' '.join(args)331 post_interp = args.encode('utf-8')332 shebang = self._get_shebang('utf-8', post_interp, options=options)333 script = self._get_script_text(entry).encode('utf-8')334 scriptnames = self.get_script_filenames(entry.name)335 if options and options.get('gui', False):336 ext = 'pyw'337 else:338 ext = 'py'339 self._write_script(scriptnames, shebang, script, filenames, ext)340 341 def _copy_script(self, script, filenames):342 adjust = False343 script = os.path.join(self.source_dir, convert_path(script))344 outname = os.path.join(self.target_dir, os.path.basename(script))345 if not self.force and not self._fileop.newer(script, outname):346 logger.debug('not copying %s (up-to-date)', script)347 return348 349 # Always open the file, but ignore failures in dry-run mode --350 # that way, we'll get accurate feedback if we can read the351 # script.352 try:353 f = open(script, 'rb')354 except IOError: # pragma: no cover355 if not self.dry_run:356 raise357 f = None358 else:359 first_line = f.readline()360 if not first_line: # pragma: no cover361 logger.warning('%s is an empty file (skipping)', script)362 return363 364 match = FIRST_LINE_RE.match(first_line.replace(b'\r\n', b'\n'))365 if match:366 adjust = True367 post_interp = match.group(1) or b''368 369 if not adjust:370 if f:371 f.close()372 self._fileop.copy_file(script, outname)373 if self.set_mode:374 self._fileop.set_executable_mode([outname])375 filenames.append(outname)376 else:377 logger.info('copying and adjusting %s -> %s', script,378 self.target_dir)379 if not self._fileop.dry_run:380 encoding, lines = detect_encoding(f.readline)381 f.seek(0)382 shebang = self._get_shebang(encoding, post_interp)383 if b'pythonw' in first_line: # pragma: no cover384 ext = 'pyw'385 else:386 ext = 'py'387 n = os.path.basename(outname)388 self._write_script([n], shebang, f.read(), filenames, ext)389 if f:390 f.close()391 392 @property393 def dry_run(self):394 return self._fileop.dry_run395 396 @dry_run.setter397 def dry_run(self, value):398 self._fileop.dry_run = value399 400 if os.name == 'nt' or (os.name == 'java'401 and os._name == 'nt'): # pragma: no cover402 # Executable launcher support.403 # Launchers are from https://bitbucket.org/vinay.sajip/simple_launcher/404 405 def _get_launcher(self, kind):406 if struct.calcsize('P') == 8: # 64-bit407 bits = '64'408 else:409 bits = '32'410 platform_suffix = '-arm' if get_platform() == 'win-arm64' else ''411 name = '%s%s%s.exe' % (kind, bits, platform_suffix)412 # Issue 31: don't hardcode an absolute package name, but413 # determine it relative to the current package414 distlib_package = __name__.rsplit('.', 1)[0]415 resource = finder(distlib_package).find(name)416 if not resource:417 msg = ('Unable to find resource %s in package %s' %418 (name, distlib_package))419 raise ValueError(msg)420 return resource.bytes421 422 # Public API follows423 424 def make(self, specification, options=None):425 """426 Make a script.427 428 :param specification: The specification, which is either a valid export429 entry specification (to make a script from a430 callable) or a filename (to make a script by431 copying from a source location).432 :param options: A dictionary of options controlling script generation.433 :return: A list of all absolute pathnames written to.434 """435 filenames = []436 entry = get_export_entry(specification)437 if entry is None:438 self._copy_script(specification, filenames)439 else:440 self._make_script(entry, filenames, options=options)441 return filenames442 443 def make_multiple(self, specifications, options=None):444 """445 Take a list of specifications and make scripts from them,446 :param specifications: A list of specifications.447 :return: A list of all absolute pathnames written to,448 """449 filenames = []450 for specification in specifications:451 filenames.extend(self.make(specification, options))452 return filenames453 