aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGES.txt9
-rw-r--r--MANIFEST.in2
-rwxr-xr-xsetuptools/command/easy_install.py212
-rwxr-xr-xsetuptools/command/install_scripts.py10
-rw-r--r--setuptools/tests/test_easy_install.py75
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()