build_scripts.py 5.48 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
"""distutils.command.build_scripts

Implements the Distutils 'build_scripts' command."""

import os
import re
from stat import ST_MODE
from distutils import sysconfig
from distutils.core import Command
from distutils.dep_util import newer
from distutils.util import convert_path
from distutils import log
import tokenize

shebang_pattern = re.compile('^#!.*python[0-9.]*([ \t].*)?$')
"""
Pattern matching a Python interpreter indicated in first line of a script.
"""

# for Setuptools compatibility
first_line_re = shebang_pattern


class build_scripts(Command):

    description = "\"build\" scripts (copy and fixup #! line)"

    user_options = [
        ('build-dir=', 'd', "directory to \"build\" (copy) to"),
        ('force', 'f', "forcibly build everything (ignore file timestamps"),
        ('executable=', 'e', "specify final destination interpreter path"),
    ]

    boolean_options = ['force']

    def initialize_options(self):
        self.build_dir = None
        self.scripts = None
        self.force = None
        self.executable = None

    def finalize_options(self):
        self.set_undefined_options(
            'build',
            ('build_scripts', 'build_dir'),
            ('force', 'force'),
            ('executable', 'executable'),
        )
        self.scripts = self.distribution.scripts

    def get_source_files(self):
        return self.scripts

    def run(self):
        if not self.scripts:
            return
        self.copy_scripts()

    def copy_scripts(self):
        """
        Copy each script listed in ``self.scripts``.

        If a script is marked as a Python script (first line matches
        'shebang_pattern', i.e. starts with ``#!`` and contains
        "python"), then adjust in the copy the first line to refer to
        the current Python interpreter.
        """
        self.mkpath(self.build_dir)
        outfiles = []
        updated_files = []
        for script in self.scripts:
            self._copy_script(script, outfiles, updated_files)

        self._change_modes(outfiles)

        return outfiles, updated_files

    def _copy_script(self, script, outfiles, updated_files):
        shebang_match = None
        script = convert_path(script)
        outfile = os.path.join(self.build_dir, os.path.basename(script))
        outfiles.append(outfile)

        if not self.force and not newer(script, outfile):
            log.debug("not copying %s (up-to-date)", script)
            return

        # Always open the file, but ignore failures in dry-run mode
        # in order to attempt to copy directly.
        try:
            f = tokenize.open(script)
        except OSError:
            if not self.dry_run:
                raise
            f = None
        else:
            first_line = f.readline()
            if not first_line:
                self.warn("%s is an empty file (skipping)" % script)
                return

            shebang_match = shebang_pattern.match(first_line)

        updated_files.append(outfile)
        if shebang_match:
            log.info("copying and adjusting %s -> %s", script, self.build_dir)
            if not self.dry_run:
                if not sysconfig.python_build:
                    executable = self.executable
                else:
                    executable = os.path.join(
                        sysconfig.get_config_var("BINDIR"),
                        "python%s%s"
                        % (
                            sysconfig.get_config_var("VERSION"),
                            sysconfig.get_config_var("EXE"),
                        ),
                    )
                post_interp = shebang_match.group(1) or ''
                shebang = "#!" + executable + post_interp + "\n"
                self._validate_shebang(shebang, f.encoding)
                with open(outfile, "w", encoding=f.encoding) as outf:
                    outf.write(shebang)
                    outf.writelines(f.readlines())
            if f:
                f.close()
        else:
            if f:
                f.close()
            self.copy_file(script, outfile)

    def _change_modes(self, outfiles):
        if os.name != 'posix':
            return

        for file in outfiles:
            self._change_mode(file)

    def _change_mode(self, file):
        if self.dry_run:
            log.info("changing mode of %s", file)
            return

        oldmode = os.stat(file)[ST_MODE] & 0o7777
        newmode = (oldmode | 0o555) & 0o7777
        if newmode != oldmode:
            log.info("changing mode of %s from %o to %o", file, oldmode, newmode)
            os.chmod(file, newmode)

    @staticmethod
    def _validate_shebang(shebang, encoding):
        # Python parser starts to read a script using UTF-8 until
        # it gets a #coding:xxx cookie. The shebang has to be the
        # first line of a file, the #coding:xxx cookie cannot be
        # written before. So the shebang has to be encodable to
        # UTF-8.
        try:
            shebang.encode('utf-8')
        except UnicodeEncodeError:
            raise ValueError(
                "The shebang ({!r}) is not encodable " "to utf-8".format(shebang)
            )

        # If the script is encoded to a custom encoding (use a
        # #coding:xxx cookie), the shebang has to be encodable to
        # the script encoding too.
        try:
            shebang.encode(encoding)
        except UnicodeEncodeError:
            raise ValueError(
                "The shebang ({!r}) is not encodable "
                "to the script encoding ({})".format(shebang, encoding)
            )