Repository: pypa/pip

Issue:
Silence Byte Compile Errors
It's confusing when installation succeeds but an error traceback is output because byte compiling a file failed. We should just hide all of that.



Discussion:

Resolving patch:
diff --git a/pip/req/req_install.py b/pip/req/req_install.py
--- a/pip/req/req_install.py
+++ b/pip/req/req_install.py
@@ -872,6 +872,8 @@ def _filter_install(self, line):
                 r'^reading .*',
                 r"^removing .*\.egg-info' \(and everything under it\)$",
                 r'^byte-compiling ',
+                r'^SyntaxError:',
+                r'^SyntaxWarning:',
                 # Not sure what this warning is, but it seems harmless:
                 r"^warning: manifest_maker: standard file '-c' not found$"]:
             if re.search(regex, line.strip()):
diff --git a/pip/util.py b/pip/util.py
--- a/pip/util.py
+++ b/pip/util.py
@@ -1,3 +1,4 @@
+import contextlib
 import locale
 import re
 import os
@@ -19,6 +20,13 @@
 from pip._vendor import pkg_resources, six
 from pip._vendor.distlib import version
 from pip._vendor.six.moves import input
+from pip._vendor.six.moves import cStringIO
+from pip._vendor.six import PY2
+
+if PY2:
+    from io import BytesIO as StringIO
+else:
+    from io import StringIO
 
 __all__ = ['rmtree', 'display_path', 'backup_dir',
            'find_command', 'ask', 'Inf',
@@ -28,7 +36,8 @@
            'split_leading_dir', 'has_leading_dir',
            'make_path_relative', 'normalize_path',
            'renames', 'get_terminal_size', 'get_prog',
-           'unzip_file', 'untar_file', 'unpack_file', 'call_subprocess']
+           'unzip_file', 'untar_file', 'unpack_file', 'call_subprocess',
+           'captured_stdout', 'remove_tracebacks']
 
 
 def get_prog():
@@ -643,6 +652,17 @@ def unpack_file(filename, location, content_type, link):
         )
 
 
+def remove_tracebacks(output):
+    pattern = (r'\W+File "(.*)", line (.*)\W+(.*)\W+\^\W+'
+               r'Syntax(Error|Warning): (.*)')
+    output = re.sub(pattern, '', output)
+    if PY2:
+        return output
+    # compileall.compile_dir() prints different messages to stdout
+    # in Python 3
+    return re.sub(r"\*\*\* Error compiling (.*)", '', output)
+
+
 def call_subprocess(cmd, show_stdout=True,
                     filter_stdout=None, cwd=None,
                     raise_on_returncode=True,
@@ -673,9 +693,10 @@ def call_subprocess(cmd, show_stdout=True,
         raise
     all_output = []
     if stdout is not None:
-        stdout = proc.stdout
+        stdout = remove_tracebacks(console_to_str(proc.stdout.read()))
+        stdout = cStringIO(stdout)
         while 1:
-            line = console_to_str(stdout.readline())
+            line = stdout.readline()
             if not line:
                 break
             line = line.rstrip()
@@ -711,7 +732,7 @@ def call_subprocess(cmd, show_stdout=True,
                 'Command "%s" had error code %s in %s'
                 % (command_desc, proc.returncode, cwd))
     if stdout is not None:
-        return ''.join(all_output)
+        return remove_tracebacks(''.join(all_output))
 
 
 def is_prerelease(vers):
@@ -780,3 +801,43 @@ def readline(self):
 
     def __iter__(self):
         return self._gen
+
+
+class StreamWrapper(StringIO):
+
+    @classmethod
+    def from_stream(cls, orig_stream):
+        cls.orig_stream = orig_stream
+        return cls()
+
+    # compileall.compile_dir() needs stdout.encoding to print to stdout
+    @property
+    def encoding(self):
+        return self.orig_stream.encoding
+
+
+@contextlib.contextmanager
+def captured_output(stream_name):
+    """Return a context manager used by captured_stdout/stdin/stderr
+    that temporarily replaces the sys stream *stream_name* with a StringIO.
+
+    Taken from Lib/support/__init__.py in the CPython repo.
+    """
+    orig_stdout = getattr(sys, stream_name)
+    setattr(sys, stream_name, StreamWrapper.from_stream(orig_stdout))
+    try:
+        yield getattr(sys, stream_name)
+    finally:
+        setattr(sys, stream_name, orig_stdout)
+
+
+def captured_stdout():
+    """Capture the output of sys.stdout:
+
+       with captured_stdout() as stdout:
+           print('hello')
+       self.assertEqual(stdout.getvalue(), 'hello\n')
+
+    Taken from Lib/support/__init__.py in the CPython repo.
+    """
+    return captured_output('stdout')
diff --git a/pip/wheel.py b/pip/wheel.py
--- a/pip/wheel.py
+++ b/pip/wheel.py
@@ -20,10 +20,12 @@
 from pip.locations import distutils_scheme
 from pip.log import logger
 from pip import pep425tags
-from pip.util import call_subprocess, normalize_path, make_path_relative
+from pip.util import (call_subprocess, normalize_path, make_path_relative,
+                      captured_stdout, remove_tracebacks)
 from pip._vendor.distlib.scripts import ScriptMaker
 from pip._vendor import pkg_resources
 from pip._vendor.six.moves import configparser
+from pip._vendor.six import PY2
 
 
 wheel_ext = '.whl'
@@ -153,7 +155,18 @@ def move_wheel_files(name, req, wheeldir, user=False, home=None, root=None,
 
     # Compile all of the pyc files that we're going to be installing
     if pycompile:
-        compileall.compile_dir(source, force=True, quiet=True)
+        with captured_stdout() as stdout:
+            compileall.compile_dir(source, force=True, quiet=True)
+        # compileall.compile_dir() prints different exception to stdout
+        # in Python 2, so we need a new helper to remove tracebacks
+        # for Python 3
+        if PY2:
+            lines = [line for line in stdout.getvalue().splitlines()
+                     if not line.startswith(('SyntaxError:',
+                                             'SyntaxWarning:'))]
+            print('\n'.join(lines))
+        else:
+            print(remove_tracebacks(stdout.getvalue()))
 
     def normpath(src, p):
         return make_path_relative(src, p).replace(os.path.sep, '/')


Test patch:
diff --git a/tests/data/packages/compilewheel-1.0-py2.py3-none-any.whl b/tests/data/packages/compilewheel-1.0-py2.py3-none-any.whl
new file mode 100644
Binary files /dev/null and b/tests/data/packages/compilewheel-1.0-py2.py3-none-any.whl differ
diff --git a/tests/data/src/compilewheel/setup.cfg b/tests/data/src/compilewheel/setup.cfg
new file mode 100644
--- /dev/null
+++ b/tests/data/src/compilewheel/setup.cfg
@@ -0,0 +1,5 @@
+[bdist_wheel]
+# This flag says that the code is written to work on both Python 2 and Python
+# 3. If at all possible, it is good practice to do this. If you cannot, you
+# will need to generate wheels for each Python version that you support.
+universal=1
diff --git a/tests/data/src/compilewheel/setup.py b/tests/data/src/compilewheel/setup.py
new file mode 100644
--- /dev/null
+++ b/tests/data/src/compilewheel/setup.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python
+from setuptools import setup, find_packages
+
+setup(name='compilewheel',
+      version='1.0',
+      packages=find_packages()
+      )
diff --git a/tests/data/src/compilewheel/simple/__init__.py b/tests/data/src/compilewheel/simple/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/data/src/compilewheel/simple/__init__.py
@@ -0,0 +1,2 @@
+def spam(gen):
+    yield from gen
diff --git a/tests/functional/test_install_wheel.py b/tests/functional/test_install_wheel.py
--- a/tests/functional/test_install_wheel.py
+++ b/tests/functional/test_install_wheel.py
@@ -314,3 +314,9 @@ def test_install_from_wheel_uninstalls_old_version(script, data):
     assert dist_info_folder in result.files_created
     dist_info_folder = script.site_packages / 'simplewheel-1.0.dist-info'
     assert dist_info_folder not in result.files_created
+
+
+def test_wheel_compile_syntax_error(script, data):
+    package = data.packages.join("compilewheel-1.0-py2.py3-none-any.whl")
+    result = script.pip('install', '--compile', package, '--no-index')
+    assert 'SyntaxError: ' not in result.stdout