Repository: pex-tool/pex

Issue:
support setuptools scripts
In order to have any executable script as entry point, pex should support the `scripts` section in `setup()`.
From pantsbuild/pants#3399
Doc: [setuptools `scripts`](https://python-packaging.readthedocs.io/en/latest/command-line-scripts.html#the-scripts-keyword-argument)




Discussion:

Resolving patch:
diff --git a/pex/finders.py b/pex/finders.py
--- a/pex/finders.py
+++ b/pex/finders.py
@@ -35,6 +35,10 @@ def read_contents(self):
         with open(self.path) as fp:
             return fp.read()
 
+    def is_python_script(self):
+        # type: () -> bool
+        return is_python_script(self.read_contents(), self.path)
+
 
 def get_script_from_distributions(name, dists):
     for dist in dists:
@@ -74,3 +78,32 @@ def get_entrypoint(dist):
     if entries:
         dist, entry_point = next(iter(entries.values()))
     return dist, entry_point
+
+
+# Copied from
+# https://github.com/pypa/setuptools/blob/a4dbe3457d89cf67ee3aa571fdb149e6eb544e88/setuptools/command/easy_install.py#L1893-L1900
+def is_python(text, filename="<string>"):
+    # type: (str, str) -> bool
+    "Is this string a valid Python script?"
+    try:
+        compile(text, filename, "exec")
+    except (SyntaxError, TypeError):
+        return False
+    else:
+        return True
+
+
+# Copied from
+# https://github.com/pypa/setuptools/blob/a4dbe3457d89cf67ee3aa571fdb149e6eb544e88/setuptools/command/easy_install.py#L1918-L1929
+def is_python_script(script_text, filename):
+    # type: (str, str) -> bool
+    """Is this text, as a whole, a Python script? (as opposed to shell/bat/etc."""
+    if filename.endswith(".py") or filename.endswith(".pyw"):
+        return True  # extension says it's Python
+    if is_python(script_text, filename):
+        return True  # it's syntactically valid Python
+    if script_text.startswith("#!"):
+        # It begins with a '#!' line, so check if 'python' is in it somewhere
+        return "python" in script_text.splitlines()[0].lower()
+
+    return False  # Not any Python I can recognize
diff --git a/pex/pex.py b/pex/pex.py
--- a/pex/pex.py
+++ b/pex/pex.py
@@ -617,10 +617,23 @@ def execute_script(self, script_name):
         dist_script = get_script_from_distributions(script_name, dists)
         if not dist_script:
             return "Could not find script {!r} in pex!".format(script_name)
-        TRACER.log("Found script {!r} in {!r}.".format(script_name, dist))
-        return self.execute_content(
-            dist_script.path, dist_script.read_contents(), argv0=script_name
-        )
+
+        TRACER.log("Found script {!r} in {!r}.".format(script_name, dist_script.dist))
+        if dist_script.is_python_script():
+            return self.execute_content(
+                dist_script.path, dist_script.read_contents(), argv0=script_name
+            )
+        else:
+            return self.execute_external(dist_script.path)
+
+    @staticmethod
+    def execute_external(cmd):
+        # type: (str) -> Any
+        try:
+            proc = Executor.open_process([cmd] + sys.argv[:1])
+            return proc.wait()
+        except Executor.ExecutionError as e:
+            return "Could not invoke script: {}".format(e)
 
     @classmethod
     def execute_content(
@@ -633,10 +646,8 @@ def execute_content(
         argv0 = argv0 or name
         try:
             ast = compile(content, name, "exec", flags=0, dont_inherit=1)
-        except SyntaxError:
-            return "Unable to parse {}. PEX script support only supports Python scripts.".format(
-                name
-            )
+        except SyntaxError as e:
+            return "Unable to parse {}: {}".format(name, e)
 
         cls.demote_bootstrap()
 


Test patch:
diff --git a/pex/testing.py b/pex/testing.py
--- a/pex/testing.py
+++ b/pex/testing.py
@@ -137,8 +137,8 @@ def make_project(
             )
             """
         ),
-        "scripts/hello_world": '#!/usr/bin/env python\nprint("hello world!")\n',
-        "scripts/shell_script": "#!/usr/bin/env bash\necho hello world\n",
+        "scripts/hello_world": '#!/usr/bin/env python\nprint("hello world from py script!")\n',
+        "scripts/shell_script": "#!/usr/bin/env bash\necho hello world from shell script\n",
         os.path.join(name, "__init__.py"): 0,
         os.path.join(name, "my_module.py"): 'def do_something():\n  print("hello world!")\n',
         os.path.join(name, "package_data/resource1.dat"): 1000,
diff --git a/tests/test_pex.py b/tests/test_pex.py
--- a/tests/test_pex.py
+++ b/tests/test_pex.py
@@ -269,8 +269,8 @@ def test_pex_script(project_name, zip_safe):
 
         env_copy["PEX_SCRIPT"] = "shell_script"
         so, rc = run_simple_pex_test("", env=env_copy, dists=[bdist_path])
-        assert rc == 1, so.decode("utf-8")
-        assert b"Unable to parse" in so
+        assert rc == 0, so.decode("utf-8")
+        assert b"hello world from shell script" in so
 
 
 def test_pex_run():
diff --git a/tests/test_pex_builder.py b/tests/test_pex_builder.py
--- a/tests/test_pex_builder.py
+++ b/tests/test_pex_builder.py
@@ -269,7 +269,7 @@ def verify(pb):
         verify(pb4)
 
 
-def test_prex_builder_script_from_pex_path(tmpdir):
+def test_pex_builder_script_from_pex_path(tmpdir):
     # type: (Any) -> None
 
     pex_with_script = os.path.join(str(tmpdir), "script.pex")
@@ -288,3 +288,20 @@ def test_prex_builder_script_from_pex_path(tmpdir):
     pb.build(pex_file)
 
     assert "hello world!\n" == subprocess.check_output(args=[pex_file]).decode("utf-8")
+
+
+def test_pex_builder_setuptools_script(tmpdir):
+    # type: (Any) -> None
+
+    pex_file = os.path.join(str(tmpdir), "app.pex")
+    with built_wheel(
+        name="my_project",
+    ) as my_whl:
+        pb = PEXBuilder()
+        pb.add_dist_location(my_whl)
+        pb.set_script("shell_script")
+        pb.build(pex_file)
+
+    assert "hello world from shell script\n" == subprocess.check_output(args=[pex_file]).decode(
+        "utf-8"
+    )