diff options
-rw-r--r-- | CHANGES.txt | 9 | ||||
-rw-r--r-- | MANIFEST.in | 2 | ||||
-rwxr-xr-x | setuptools/command/easy_install.py | 212 | ||||
-rwxr-xr-x | setuptools/command/install_scripts.py | 10 | ||||
-rw-r--r-- | setuptools/tests/test_easy_install.py | 75 |
5 files changed, 215 insertions, 93 deletions
diff --git a/CHANGES.txt b/CHANGES.txt index 5fce5d9c..afec8156 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -2,6 +2,15 @@ CHANGES ======= +---- +12.0 +---- + +* Issue #188: Setuptools now support multiple entities in the value for + ``build.executable``, such that an executable of "/usr/bin/env my-python" may + be specified. This means that systems with a specified executable whose name + has spaces in the path must be updated to escape or quote that value. + ------ 11.3.1 ------ diff --git a/MANIFEST.in b/MANIFEST.in index ed60948b..428bbd1e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ recursive-include setuptools *.py *.exe *.xml -recursive-include tests *.py *.c *.pyx +recursive-include tests *.py recursive-include setuptools/tests *.html recursive-include docs *.py *.txt *.conf *.css *.css_t Makefile indexsidebar.html recursive-include _markerlib *.py diff --git a/setuptools/command/easy_install.py b/setuptools/command/easy_install.py index d05f4c65..340b1fac 100755 --- a/setuptools/command/easy_install.py +++ b/setuptools/command/easy_install.py @@ -35,6 +35,8 @@ import warnings import site import struct import contextlib +import subprocess +import shlex from setuptools import Command from setuptools.sandbox import run_setup @@ -59,10 +61,6 @@ import pkg_resources warnings.filterwarnings("default", category=pkg_resources.PEP440Warning) -sys_executable = os.environ.get('__PYVENV_LAUNCHER__', - os.path.normpath(sys.executable)) - - __all__ = [ 'samefile', 'easy_install', 'PthDistributions', 'extract_wininst_cfg', 'main', 'get_exe_prefixes', @@ -744,7 +742,7 @@ Please make the appropriate changes for your system and try again. def install_wrapper_scripts(self, dist): if not self.exclude_scripts: - for args in get_script_args(dist): + for args in ScriptWriter.get_args(dist): self.write_script(*args) def install_script(self, dist, script_name, script_text, dev_path=None): @@ -753,7 +751,7 @@ Please make the appropriate changes for your system and try again. is_script = is_python_script(script_text, script_name) if is_script: - script_text = (get_script_header(script_text) + + script_text = (ScriptWriter.get_header(script_text) + self._load_template(dev_path) % locals()) self.write_script(script_name, _to_ascii(script_text), 'b') @@ -917,9 +915,10 @@ Please make the appropriate changes for your system and try again. f.write('%s: %s\n' % (k.replace('_', '-').title(), v)) f.close() script_dir = os.path.join(_egg_info, 'scripts') - self.delete_blockers( # delete entry-point scripts to avoid duping + # delete entry-point scripts to avoid duping + self.delete_blockers( [os.path.join(script_dir, args[0]) for args in - get_script_args(dist)] + ScriptWriter.get_args(dist)] ) # Build .egg file from tmpdir bdist_egg.make_zipfile( @@ -1590,33 +1589,6 @@ def _first_line_re(): return re.compile(first_line_re.pattern.decode()) -def get_script_header(script_text, executable=sys_executable, wininst=False): - """Create a #! line, getting options (if any) from script_text""" - first = (script_text + '\n').splitlines()[0] - match = _first_line_re().match(first) - options = '' - if match: - options = match.group(1) or '' - if options: - options = ' ' + options - if wininst: - executable = "python.exe" - else: - executable = nt_quote_arg(executable) - hdr = "#!%(executable)s%(options)s\n" % locals() - if not isascii(hdr): - # Non-ascii path to sys.executable, use -x to prevent warnings - if options: - if options.strip().startswith('-'): - options = ' -x' + options.strip()[1:] - # else: punt, we can't do it, let the warning happen anyway - else: - options = ' -x' - executable = fix_jython_executable(executable, options) - hdr = "#!%(executable)s%(options)s\n" % locals() - return hdr - - def auto_chmod(func, arg, exc): if func is os.remove and os.name == 'nt': chmod(arg, stat.S_IWRITE) @@ -1825,36 +1797,7 @@ def is_sh(executable): def nt_quote_arg(arg): """Quote a command line argument according to Windows parsing rules""" - - result = [] - needquote = False - nb = 0 - - needquote = (" " in arg) or ("\t" in arg) - if needquote: - result.append('"') - - for c in arg: - if c == '\\': - nb += 1 - elif c == '"': - # double preceding backslashes, then add a \" - result.append('\\' * (nb * 2) + '\\"') - nb = 0 - else: - if nb: - result.append('\\' * nb) - nb = 0 - result.append(c) - - if nb: - result.append('\\' * nb) - - if needquote: - result.append('\\' * nb) # double the trailing backslashes - result.append('"') - - return ''.join(result) + return subprocess.list2cmdline([arg]) def is_python_script(script_text, filename): @@ -1909,6 +1852,107 @@ def fix_jython_executable(executable, options): return executable +class CommandSpec(list): + """ + A command spec for a #! header, specified as a list of arguments akin to + those passed to Popen. + """ + + options = [] + + @classmethod + def _sys_executable(cls): + _default = os.path.normpath(sys.executable) + return os.environ.get('__PYVENV_LAUNCHER__', _default) + + @classmethod + def from_param(cls, param): + """ + Construct a CommandSpec from a parameter to build_scripts, which may + be None. + """ + if isinstance(param, cls): + return param + if isinstance(param, list): + return cls(param) + if param is None: + return cls.from_environment() + # otherwise, assume it's a string. + return cls.from_string(param) + + @classmethod + def from_environment(cls): + return cls.from_string('"' + cls._sys_executable() + '"') + + @classmethod + def from_string(cls, string): + """ + Construct a command spec from a simple string representing a command + line parseable by shlex.split. + """ + items = shlex.split(string) + return JythonCommandSpec.from_string(string) or cls(items) + + def install_options(self, script_text): + 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 _render(items): + cmdline = subprocess.list2cmdline(items) + return '#!' + cmdline + '\n' + + +class JythonCommandSpec(CommandSpec): + @classmethod + def from_string(cls, string): + """ + On Jython, construct an instance of this class. + On platforms other than Jython, return None. + """ + needs_jython_spec = ( + sys.platform.startswith('java') + and + __import__('java').lang.System.getProperty('os.name') != 'Linux' + ) + return cls([string]) if needs_jython_spec else None + + def as_header(self): + """ + Workaround Jython's sys.executable being a .sh (an invalid + shebang line interpreter) + """ + if not is_sh(self[0]): + return super(JythonCommandSpec, self).as_header() + + if self.options: + # Can't apply the workaround, leave it broken + log.warn( + "WARNING: Unable to adapt shebang line for Jython," + " the following script is NOT executable\n" + " see http://bugs.jython.org/issue1112 for" + " more information.") + return super(JythonCommandSpec, self).as_header() + + items = ['/usr/bin/env'] + self + list(self.options) + return self._render(items) + + class ScriptWriter(object): """ Encapsulates behavior around writing entry point scripts for console and @@ -1928,19 +1972,37 @@ class ScriptWriter(object): """).lstrip() @classmethod - def get_script_args(cls, dist, executable=sys_executable, wininst=False): + def get_script_args(cls, dist, executable=None, wininst=False): + # for backward compatibility + warnings.warn("Use get_args", DeprecationWarning) + writer = cls.get_writer(wininst) + header = cls.get_script_header("", executable, wininst) + return writer.get_args(dist, header) + + @classmethod + def get_script_header(cls, script_text, executable=None, wininst=False): + # for backward compatibility + warnings.warn("Use get_header", DeprecationWarning) + if wininst: + executable = "python.exe" + cmd = CommandSpec.from_param(executable) + cmd.install_options(script_text) + return cmd.as_header() + + @classmethod + def get_args(cls, dist, header=None): """ Yield write_script() argument tuples for a distribution's entrypoints """ - gen_class = cls.get_writer(wininst) + if header is None: + header = cls.get_header() spec = str(dist.as_requirement()) - header = get_script_header("", executable, wininst) for type_ in 'console', 'gui': group = type_ + '_scripts' for name, ep in dist.get_entry_map(group).items(): - script_text = gen_class.template % locals() - for res in gen_class._get_script_args(type_, name, header, - script_text): + script_text = cls.template % locals() + for res in cls._get_script_args(type_, name, header, + script_text): yield res @classmethod @@ -1954,6 +2016,13 @@ class ScriptWriter(object): # Simply write the stub with no extension. yield (name, header + script_text) + @classmethod + def get_header(cls, script_text="", executable=None): + """Create a #! line, getting options (if any) from script_text""" + cmd = CommandSpec.from_param(executable) + cmd.install_options(script_text) + return cmd.as_header() + class WindowsScriptWriter(ScriptWriter): @classmethod @@ -2034,6 +2103,7 @@ class WindowsExecutableLauncherWriter(WindowsScriptWriter): # for backward-compatibility get_script_args = ScriptWriter.get_script_args +get_script_header = ScriptWriter.get_script_header def get_win_launcher(type): diff --git a/setuptools/command/install_scripts.py b/setuptools/command/install_scripts.py index eb79fa3c..722b0566 100755 --- a/setuptools/command/install_scripts.py +++ b/setuptools/command/install_scripts.py @@ -13,8 +13,7 @@ class install_scripts(orig.install_scripts): self.no_ep = False def run(self): - from setuptools.command.easy_install import get_script_args - from setuptools.command.easy_install import sys_executable + from setuptools.command.easy_install import ScriptWriter, CommandSpec self.run_command("egg_info") if self.distribution.scripts: @@ -31,11 +30,14 @@ class install_scripts(orig.install_scripts): ei_cmd.egg_name, ei_cmd.egg_version, ) bs_cmd = self.get_finalized_command('build_scripts') - executable = getattr(bs_cmd, 'executable', sys_executable) + cmd = CommandSpec.from_param(getattr(bs_cmd, 'executable', None)) is_wininst = getattr( self.get_finalized_command("bdist_wininst"), '_is_running', False ) - for args in get_script_args(dist, executable, is_wininst): + if is_wininst: + cmd = CommandSpec.from_string("python.exe") + writer = ScriptWriter.get_writer(force_windows=is_wininst) + for args in writer.get_args(dist, cmd.as_header()): self.write_script(*args) def write_script(self, script_name, contents, mode="t", *ignored): diff --git a/setuptools/tests/test_easy_install.py b/setuptools/tests/test_easy_install.py index 7baa989a..72b040e1 100644 --- a/setuptools/tests/test_easy_install.py +++ b/setuptools/tests/test_easy_install.py @@ -20,15 +20,15 @@ import mock from setuptools import sandbox from setuptools import compat from setuptools.compat import StringIO, BytesIO, urlparse -from setuptools.sandbox import run_setup, SandboxViolation +from setuptools.sandbox import run_setup from setuptools.command.easy_install import ( - easy_install, fix_jython_executable, get_script_args, nt_quote_arg, - get_script_header, is_sh, + easy_install, fix_jython_executable, nt_quote_arg, + is_sh, ScriptWriter, CommandSpec, ) from setuptools.command.easy_install import PthDistributions from setuptools.command import easy_install as easy_install_pkg from setuptools.dist import Distribution -from pkg_resources import working_set, VersionConflict +from pkg_resources import working_set from pkg_resources import Distribution as PRDistribution import setuptools.tests.server import pkg_resources @@ -83,7 +83,7 @@ class TestEasyInstallTest: def test_get_script_args(self): dist = FakeDist() - args = next(get_script_args(dist)) + args = next(ScriptWriter.get_args(dist)) name, script = itertools.islice(args, 2) assert script == WANTED @@ -425,15 +425,22 @@ class TestScriptHeader: ) def test_get_script_header(self): expected = '#!%s\n' % nt_quote_arg(os.path.normpath(sys.executable)) - assert get_script_header('#!/usr/local/bin/python') == expected - expected = '#!%s -x\n' % nt_quote_arg(os.path.normpath(sys.executable)) - assert get_script_header('#!/usr/bin/python -x') == expected - candidate = get_script_header('#!/usr/bin/python', + actual = ScriptWriter.get_script_header('#!/usr/local/bin/python') + assert actual == expected + + expected = '#!%s -x\n' % nt_quote_arg(os.path.normpath(sys.executable)) + actual = ScriptWriter.get_script_header('#!/usr/bin/python -x') + assert actual == expected + + actual = ScriptWriter.get_script_header('#!/usr/bin/python', executable=self.non_ascii_exe) - assert candidate == '#!%s -x\n' % self.non_ascii_exe - candidate = get_script_header('#!/usr/bin/python', - executable=self.exe_with_spaces) - assert candidate == '#!"%s"\n' % self.exe_with_spaces + expected = '#!%s -x\n' % self.non_ascii_exe + assert actual == expected + + actual = ScriptWriter.get_script_header('#!/usr/bin/python', + executable='"'+self.exe_with_spaces+'"') + expected = '#!"%s"\n' % self.exe_with_spaces + assert actual == expected @pytest.mark.xfail( compat.PY3 and os.environ.get("LC_CTYPE") in ("C", "POSIX"), @@ -453,7 +460,8 @@ class TestScriptHeader: f.write(header) exe = str(exe) - header = get_script_header('#!/usr/local/bin/python', executable=exe) + header = ScriptWriter.get_script_header('#!/usr/local/bin/python', + executable=exe) assert header == '#!/usr/bin/env %s\n' % exe expect_out = 'stdout' if sys.version_info < (2,7) else 'stderr' @@ -461,15 +469,48 @@ class TestScriptHeader: with contexts.quiet() as (stdout, stderr): # When options are included, generate a broken shebang line # with a warning emitted - candidate = get_script_header('#!/usr/bin/python -x', + candidate = ScriptWriter.get_script_header('#!/usr/bin/python -x', executable=exe) - assert candidate == '#!%s -x\n' % exe + assert candidate == '#!%s -x\n' % exe output = locals()[expect_out] assert 'Unable to adapt shebang line' in output.getvalue() with contexts.quiet() as (stdout, stderr): - candidate = get_script_header('#!/usr/bin/python', + candidate = ScriptWriter.get_script_header('#!/usr/bin/python', executable=self.non_ascii_exe) assert candidate == '#!%s -x\n' % self.non_ascii_exe output = locals()[expect_out] assert 'Unable to adapt shebang line' in output.getvalue() + + +class TestCommandSpec: + def test_custom_launch_command(self): + """ + Show how a custom CommandSpec could be used to specify a #! executable + which takes parameters. + """ + cmd = CommandSpec(['/usr/bin/env', 'python3']) + assert cmd.as_header() == '#!/usr/bin/env python3\n' + + def test_from_param_for_CommandSpec_is_passthrough(self): + """ + from_param should return an instance of a CommandSpec + """ + cmd = CommandSpec(['python']) + cmd_new = CommandSpec.from_param(cmd) + assert cmd is cmd_new + + def test_from_environment_with_spaces_in_executable(self): + with mock.patch('sys.executable', TestScriptHeader.exe_with_spaces): + cmd = CommandSpec.from_environment() + assert len(cmd) == 1 + assert cmd.as_header().startswith('#!"') + + def test_from_simple_string_uses_shlex(self): + """ + In order to support `executable = /usr/bin/env my-python`, make sure + from_param invokes shlex on that input. + """ + cmd = CommandSpec.from_param('/usr/bin/env my-python') + assert len(cmd) == 2 + assert '"' not in cmd.as_header() |