python (3.11.7)
       1  # -*- coding: utf-8 -*-
       2  #
       3  # Copyright (C) 2013-2015 Vinay Sajip.
       4  # Licensed to the Python Software Foundation under a contributor agreement.
       5  # See LICENSE.txt and CONTRIBUTORS.txt.
       6  #
       7  from io import BytesIO
       8  import logging
       9  import os
      10  import re
      11  import struct
      12  import sys
      13  import time
      14  from zipfile import ZipInfo
      15  
      16  from .compat import sysconfig, detect_encoding, ZipFile
      17  from .resources import finder
      18  from .util import (FileOperator, get_export_entry, convert_path,
      19                     get_executable, get_platform, in_venv)
      20  
      21  logger = 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 expression
      42  FIRST_LINE_RE = re.compile(b'^#!.*pythonw?[0-9.]*([ \t].*)?$')
      43  SCRIPT_TEMPLATE = r'''# -*- coding: utf-8 -*-
      44  import re
      45  import sys
      46  from %(module)s import %(import_name)s
      47  if __name__ == '__main__':
      48      sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
      49      sys.exit(%(func)s())
      50  '''
      51  
      52  
      53  def enquote_executable(executable):
      54      if ' ' in executable:
      55          # make sure we quote only the executable in case of env
      56          # for example /usr/bin/env "/dir with spaces/bin/jython"
      57          # instead of "/usr/bin/env /dir with spaces/bin/jython"
      58          # otherwise whole
      59          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"' % executable
      66      return executable
      67  
      68  # Keep the old name around (for now), as there is at least one project using it!
      69  _enquote_executable = enquote_executable
      70  
      71  class ESC[4;38;5;81mScriptMaker(ESC[4;38;5;149mobject):
      72      """
      73      A class to copy or create scripts from source scripts or callable
      74      specifications.
      75      """
      76      script_template = SCRIPT_TEMPLATE
      77  
      78      executable = None  # for shebangs
      79  
      80      def __init__(self, source_dir, target_dir, add_launchers=True,
      81                   dry_run=False, fileop=None):
      82          self.source_dir = source_dir
      83          self.target_dir = target_dir
      84          self.add_launchers = add_launchers
      85          self.force = False
      86          self.clobber = False
      87          # It only makes sense to set mode bits on POSIX.
      88          self.set_mode = (os.name == 'posix') or (os.name == 'java' and
      89                                                   os._name == 'posix')
      90          self.variants = set(('', 'X.Y'))
      91          self._fileop = fileop or FileOperator(dry_run)
      92  
      93          self._is_nt = os.name == 'nt' or (
      94              os.name == 'java' and os._name == 'nt')
      95          self.version_info = sys.version_info
      96  
      97      def _get_alternate_executable(self, executable, options):
      98          if options.get('gui', False) and self._is_nt:  # pragma: no cover
      99              dn, fn = os.path.split(executable)
     100              fn = fn.replace('python', 'pythonw')
     101              executable = os.path.join(dn, fn)
     102          return executable
     103  
     104      if sys.platform.startswith('java'):  # pragma: no cover
     105          def _is_shell(self, executable):
     106              """
     107              Determine if the specified executable is a script
     108              (contains a #! line)
     109              """
     110              try:
     111                  with open(executable) as fp:
     112                      return fp.read(2) == '#!'
     113              except (OSError, IOError):
     114                  logger.warning('Failed to open %s', executable)
     115                  return False
     116  
     117          def _fix_jython_executable(self, executable):
     118              if self._is_shell(executable):
     119                  # Workaround for Jython is not needed on Linux systems.
     120                  import java
     121  
     122                  if java.lang.System.getProperty('os.name') == 'Linux':
     123                      return executable
     124              elif executable.lower().endswith('jython.exe'):
     125                  # Use wrapper exe for Jython on Windows
     126                  return executable
     127              return '/usr/bin/env %s' % executable
     128  
     129      def _build_shebang(self, executable, post_interp):
     130          """
     131          Build a shebang line. In the simple case (on Windows, or a shebang line
     132          which is not too long or contains spaces) use a simple formulation for
     133          the shebang. Otherwise, use /bin/sh as the executable, with a contrived
     134          shebang which allows the script to run either under Python or sh, using
     135          suitable quoting. Thanks to Harald Nordgren for his input.
     136  
     137          See also: http://www.in-ulm.de/~mascheck/various/shebang/#length
     138                    https://hg.mozilla.org/mozilla-central/file/tip/mach
     139          """
     140          if os.name != 'posix':
     141              simple_shebang = True
     142          else:
     143              # Add 3 for '#!' prefix and newline suffix.
     144              shebang_length = len(executable) + len(post_interp) + 3
     145              if sys.platform == 'darwin':
     146                  max_shebang_length = 512
     147              else:
     148                  max_shebang_length = 127
     149              simple_shebang = ((b' ' not in executable) and
     150                                (shebang_length <= max_shebang_length))
     151  
     152          if simple_shebang:
     153              result = b'#!' + executable + post_interp + b'\n'
     154          else:
     155              result = b'#!/bin/sh\n'
     156              result += b"'''exec' " + executable + post_interp + b' "$0" "$@"\n'
     157              result += b"' '''"
     158          return result
     159  
     160      def _get_shebang(self, encoding, post_interp=b'', options=None):
     161          enquote = True
     162          if self.executable:
     163              executable = self.executable
     164              enquote = False     # assume this will be taken care of
     165          elif not sysconfig.is_python_build():
     166              executable = get_executable()
     167          elif in_venv():  # pragma: no cover
     168              executable = os.path.join(sysconfig.get_path('scripts'),
     169                              'python%s' % sysconfig.get_config_var('EXE'))
     170          else:  # pragma: no cover
     171              executable = os.path.join(
     172                  sysconfig.get_config_var('BINDIR'),
     173                 'python%s%s' % (sysconfig.get_config_var('VERSION'),
     174                                 sysconfig.get_config_var('EXE')))
     175              if not os.path.isfile(executable):
     176                  # for Python builds from source on Windows, no Python executables with
     177                  # a version suffix are created, so we use python.exe
     178                  executable = os.path.join(sysconfig.get_config_var('BINDIR'),
     179                                  'python%s' % (sysconfig.get_config_var('EXE')))
     180          if options:
     181              executable = self._get_alternate_executable(executable, options)
     182  
     183          if sys.platform.startswith('java'):  # pragma: no cover
     184              executable = self._fix_jython_executable(executable)
     185  
     186          # Normalise case for Windows - COMMENTED OUT
     187          # executable = os.path.normcase(executable)
     188          # N.B. The normalising operation above has been commented out: See
     189          # issue #124. Although paths in Windows are generally case-insensitive,
     190          # they aren't always. For example, a path containing a ẞ (which is a
     191          # LATIN CAPITAL LETTER SHARP S - U+1E9E) is normcased to ß (which is a
     192          # LATIN SMALL LETTER SHARP S' - U+00DF). The two are not considered by
     193          # Windows as equivalent in path names.
     194  
     195          # If the user didn't specify an executable, it may be necessary to
     196          # cater for executable paths with spaces (not uncommon on Windows)
     197          if enquote:
     198              executable = enquote_executable(executable)
     199          # Issue #51: don't use fsencode, since we later try to
     200          # check that the shebang is decodable using utf-8.
     201          executable = executable.encode('utf-8')
     202          # in case of IronPython, play safe and enable frames support
     203          if (sys.platform == 'cli' and '-X:Frames' not in post_interp
     204              and '-X:FullFrames' not in post_interp):  # pragma: no cover
     205              post_interp += b' -X:Frames'
     206          shebang = self._build_shebang(executable, post_interp)
     207          # Python parser starts to read a script using UTF-8 until
     208          # it gets a #coding:xxx cookie. The shebang has to be the
     209          # first line of a file, the #coding:xxx cookie cannot be
     210          # written before. So the shebang has to be decodable from
     211          # UTF-8.
     212          try:
     213              shebang.decode('utf-8')
     214          except UnicodeDecodeError:  # pragma: no cover
     215              raise ValueError(
     216                  'The shebang (%r) is not decodable from utf-8' % shebang)
     217          # If the script is encoded to a custom encoding (use a
     218          # #coding:xxx cookie), the shebang has to be decodable from
     219          # the script encoding too.
     220          if encoding != 'utf-8':
     221              try:
     222                  shebang.decode(encoding)
     223              except UnicodeDecodeError:  # pragma: no cover
     224                  raise ValueError(
     225                      'The shebang (%r) is not decodable '
     226                      'from the script encoding (%r)' % (shebang, encoding))
     227          return shebang
     228  
     229      def _get_script_text(self, entry):
     230          return self.script_template % dict(module=entry.prefix,
     231                                             import_name=entry.suffix.split('.')[0],
     232                                             func=entry.suffix)
     233  
     234      manifest = _DEFAULT_MANIFEST
     235  
     236      def get_manifest(self, exename):
     237          base = os.path.basename(exename)
     238          return self.manifest % base
     239  
     240      def _write_script(self, names, shebang, script_bytes, filenames, ext):
     241          use_launcher = self.add_launchers and self._is_nt
     242          linesep = os.linesep.encode('utf-8')
     243          if not shebang.endswith(linesep):
     244              shebang += linesep
     245          if not use_launcher:
     246              script_bytes = shebang + script_bytes
     247          else:  # pragma: no cover
     248              if ext == 'py':
     249                  launcher = self._get_launcher('t')
     250              else:
     251                  launcher = self._get_launcher('w')
     252              stream = BytesIO()
     253              with ZipFile(stream, 'w') as zf:
     254                  source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH')
     255                  if source_date_epoch:
     256                      date_time = time.gmtime(int(source_date_epoch))[:6]
     257                      zinfo = ZipInfo(filename='__main__.py', date_time=date_time)
     258                      zf.writestr(zinfo, script_bytes)
     259                  else:
     260                      zf.writestr('__main__.py', script_bytes)
     261              zip_data = stream.getvalue()
     262              script_bytes = launcher + shebang + zip_data
     263          for name in names:
     264              outname = os.path.join(self.target_dir, name)
     265              if use_launcher:  # pragma: no cover
     266                  n, e = os.path.splitext(outname)
     267                  if e.startswith('.py'):
     268                      outname = n
     269                  outname = '%s.exe' % outname
     270                  try:
     271                      self._fileop.write_binary_file(outname, script_bytes)
     272                  except Exception:
     273                      # Failed writing an executable - it might be in use.
     274                      logger.warning('Failed to write executable - trying to '
     275                                     'use .deleteme logic')
     276                      dfname = '%s.deleteme' % outname
     277                      if os.path.exists(dfname):
     278                          os.remove(dfname)       # Not allowed to fail here
     279                      os.rename(outname, dfname)  # nor here
     280                      self._fileop.write_binary_file(outname, script_bytes)
     281                      logger.debug('Able to replace executable using '
     282                                   '.deleteme logic')
     283                      try:
     284                          os.remove(dfname)
     285                      except Exception:
     286                          pass    # still in use - ignore error
     287              else:
     288                  if self._is_nt and not outname.endswith('.' + ext):  # pragma: no cover
     289                      outname = '%s.%s' % (outname, ext)
     290                  if os.path.exists(outname) and not self.clobber:
     291                      logger.warning('Skipping existing file %s', outname)
     292                      continue
     293                  self._fileop.write_binary_file(outname, script_bytes)
     294                  if self.set_mode:
     295                      self._fileop.set_executable_mode([outname])
     296              filenames.append(outname)
     297  
     298      variant_separator = '-'
     299  
     300      def get_script_filenames(self, name):
     301          result = set()
     302          if '' in self.variants:
     303              result.add(name)
     304          if 'X' in self.variants:
     305              result.add('%s%s' % (name, self.version_info[0]))
     306          if 'X.Y' in self.variants:
     307              result.add('%s%s%s.%s' % (name, self.variant_separator,
     308                                        self.version_info[0], self.version_info[1]))
     309          return result
     310  
     311      def _make_script(self, entry, filenames, options=None):
     312          post_interp = b''
     313          if options:
     314              args = options.get('interpreter_args', [])
     315              if args:
     316                  args = ' %s' % ' '.join(args)
     317                  post_interp = args.encode('utf-8')
     318          shebang = self._get_shebang('utf-8', post_interp, options=options)
     319          script = self._get_script_text(entry).encode('utf-8')
     320          scriptnames = self.get_script_filenames(entry.name)
     321          if options and options.get('gui', False):
     322              ext = 'pyw'
     323          else:
     324              ext = 'py'
     325          self._write_script(scriptnames, shebang, script, filenames, ext)
     326  
     327      def _copy_script(self, script, filenames):
     328          adjust = False
     329          script = os.path.join(self.source_dir, convert_path(script))
     330          outname = os.path.join(self.target_dir, os.path.basename(script))
     331          if not self.force and not self._fileop.newer(script, outname):
     332              logger.debug('not copying %s (up-to-date)', script)
     333              return
     334  
     335          # Always open the file, but ignore failures in dry-run mode --
     336          # that way, we'll get accurate feedback if we can read the
     337          # script.
     338          try:
     339              f = open(script, 'rb')
     340          except IOError:  # pragma: no cover
     341              if not self.dry_run:
     342                  raise
     343              f = None
     344          else:
     345              first_line = f.readline()
     346              if not first_line:  # pragma: no cover
     347                  logger.warning('%s is an empty file (skipping)', script)
     348                  return
     349  
     350              match = FIRST_LINE_RE.match(first_line.replace(b'\r\n', b'\n'))
     351              if match:
     352                  adjust = True
     353                  post_interp = match.group(1) or b''
     354  
     355          if not adjust:
     356              if f:
     357                  f.close()
     358              self._fileop.copy_file(script, outname)
     359              if self.set_mode:
     360                  self._fileop.set_executable_mode([outname])
     361              filenames.append(outname)
     362          else:
     363              logger.info('copying and adjusting %s -> %s', script,
     364                          self.target_dir)
     365              if not self._fileop.dry_run:
     366                  encoding, lines = detect_encoding(f.readline)
     367                  f.seek(0)
     368                  shebang = self._get_shebang(encoding, post_interp)
     369                  if b'pythonw' in first_line:  # pragma: no cover
     370                      ext = 'pyw'
     371                  else:
     372                      ext = 'py'
     373                  n = os.path.basename(outname)
     374                  self._write_script([n], shebang, f.read(), filenames, ext)
     375              if f:
     376                  f.close()
     377  
     378      @property
     379      def dry_run(self):
     380          return self._fileop.dry_run
     381  
     382      @dry_run.setter
     383      def dry_run(self, value):
     384          self._fileop.dry_run = value
     385  
     386      if os.name == 'nt' or (os.name == 'java' and os._name == 'nt'):  # pragma: no cover
     387          # Executable launcher support.
     388          # Launchers are from https://bitbucket.org/vinay.sajip/simple_launcher/
     389  
     390          def _get_launcher(self, kind):
     391              if struct.calcsize('P') == 8:   # 64-bit
     392                  bits = '64'
     393              else:
     394                  bits = '32'
     395              platform_suffix = '-arm' if get_platform() == 'win-arm64' else ''
     396              name = '%s%s%s.exe' % (kind, bits, platform_suffix)
     397              # Issue 31: don't hardcode an absolute package name, but
     398              # determine it relative to the current package
     399              distlib_package = __name__.rsplit('.', 1)[0]
     400              resource = finder(distlib_package).find(name)
     401              if not resource:
     402                  msg = ('Unable to find resource %s in package %s' % (name,
     403                         distlib_package))
     404                  raise ValueError(msg)
     405              return resource.bytes
     406  
     407      # Public API follows
     408  
     409      def make(self, specification, options=None):
     410          """
     411          Make a script.
     412  
     413          :param specification: The specification, which is either a valid export
     414                                entry specification (to make a script from a
     415                                callable) or a filename (to make a script by
     416                                copying from a source location).
     417          :param options: A dictionary of options controlling script generation.
     418          :return: A list of all absolute pathnames written to.
     419          """
     420          filenames = []
     421          entry = get_export_entry(specification)
     422          if entry is None:
     423              self._copy_script(specification, filenames)
     424          else:
     425              self._make_script(entry, filenames, options=options)
     426          return filenames
     427  
     428      def make_multiple(self, specifications, options=None):
     429          """
     430          Take a list of specifications and make scripts from them,
     431          :param specifications: A list of specifications.
     432          :return: A list of all absolute pathnames written to,
     433          """
     434          filenames = []
     435          for specification in specifications:
     436              filenames.extend(self.make(specification, options))
     437          return filenames