Repository: pantsbuild/pants

Issue:
have progress listener avoid sys.stdout
As it introduces noise to stdout such as pants export, affecting other linux tooling trying to read stdout
```
100% .................................................. 18153 KB 0.592s
{
    "preferred_jvm_distributions": {
        "java7": {
            "strict": "/usr/lib/jvm/java-7-openjdk-amd64",
```

code in question https://github.com/pantsbuild/pants/blob/96e0187c96cc799e2135597859585a85d0a33758/src/python/pants/net/http/fetcher.py#L182-L182


Discussion:

Resolving patch:
diff --git a/src/python/pants/net/http/fetcher.py b/src/python/pants/net/http/fetcher.py
--- a/src/python/pants/net/http/fetcher.py
+++ b/src/python/pants/net/http/fetcher.py
@@ -145,19 +145,22 @@ def checksum(self):
       return self._checksum
 
   class ProgressListener(Listener):
-    """A Listener that logs progress to stdout."""
+    """A Listener that logs progress to a stream."""
 
-    def __init__(self, width=None, chunk_size_bytes=None):
+    def __init__(self, width=None, chunk_size_bytes=None, stream=None):
       """Creates a ProgressListener that logs progress for known size items with a progress bar of
       the given width in characters and otherwise logs a progress indicator every chunk_size.
 
       :param int width: the width of the progress bar for known size downloads, 50 by default.
       :param chunk_size_bytes: The size of data chunks to note progress for, 10 KB by default.
+      :param stream: A stream to write progress information to; `sys.stderr` by default.
+      :type stream: :class:`io.RawIOBase`
       """
       self._width = width or 50
       if not isinstance(self._width, six.integer_types):
         raise ValueError('The width must be an integer, given {}'.format(self._width))
       self._chunk_size_bytes = chunk_size_bytes or 10 * 1024
+      self._stream = stream or sys.stderr
       self._start = time.time()
 
     def status(self, code, content_length=None):
@@ -178,20 +181,20 @@ def recv_chunk(self, data):
       if chunk_count > self.chunks:
         self.chunks = chunk_count
         if self.size:
-          sys.stdout.write('\r')
-          sys.stdout.write('{:3}% '.format(int(self.read * 1.0 / self.size * 100)))
-        sys.stdout.write('.' * self.chunks)
+          self._stream.write('\r')
+          self._stream.write('{:3}% '.format(int(self.read * 1.0 / self.size * 100)))
+        self._stream.write('.' * self.chunks)
         if self.size:
           size_width = len(str(self.download_size))
           downloaded = int(self.read / 1024)
-          sys.stdout.write('{} {} KB'.format(' ' * (self._width - self.chunks),
-                                         str(downloaded).rjust(size_width)))
-        sys.stdout.flush()
+          self._stream.write('{} {} KB'.format(' ' * (self._width - self.chunks),
+                                               str(downloaded).rjust(size_width)))
+        self._stream.flush()
 
     def finished(self):
       if self.chunks > 0:
-        sys.stdout.write(' {:.3f}s\n'.format(time.time() - self._start))
-        sys.stdout.flush()
+        self._stream.write(' {:.3f}s\n'.format(time.time() - self._start))
+        self._stream.flush()
 
   def __init__(self, root_dir, requests_api=None):
     """Creates a Fetcher that uses the given requests api object.


Test patch:
diff --git a/tests/python/pants_test/net/http/test_fetcher.py b/tests/python/pants_test/net/http/test_fetcher.py
--- a/tests/python/pants_test/net/http/test_fetcher.py
+++ b/tests/python/pants_test/net/http/test_fetcher.py
@@ -94,11 +94,13 @@ def test_file_no_perms(self):
         self.fetcher.fetch(no_perms, self.listener)
 
   @contextmanager
-  def expect_get(self, url, chunk_size_bytes, timeout_secs, listener=True):
-    chunks = ['0123456789', 'a']
+  def expect_get(self, url, chunk_size_bytes, timeout_secs, chunks=None, listener=True):
+    chunks = chunks or ['0123456789', 'a']
+    size = sum(len(c) for c in chunks)
+
     self.requests.get.return_value = self.response
     self.response.status_code = 200
-    self.response.headers = {'content-length': '11'}
+    self.response.headers = {'content-length': str(size)}
     self.response.iter_content.return_value = chunks
 
     yield chunks, [self.ok_call(chunks)] if listener else []
@@ -284,6 +286,29 @@ def test_download_path(self):
       with open(path) as fp:
         self.assertEqual(downloaded, fp.read())
 
+  @mock.patch('time.time')
+  def test_progress_listener(self, timer):
+    timer.side_effect = [0, 1.137]
+
+    stream = StringIO()
+    progress_listener = Fetcher.ProgressListener(width=5, chunk_size_bytes=1, stream=stream)
+
+    with self.expect_get('http://baz',
+                         chunk_size_bytes=1,
+                         timeout_secs=37,
+                         chunks=[[1]] * 1024) as (chunks, expected_listener_calls):
+
+      self.fetcher.fetch('http://baz',
+                         progress_listener.wrap(self.listener),
+                         chunk_size_bytes=1,
+                         timeout_secs=37)
+
+    self.assert_listener_calls(expected_listener_calls, chunks)
+
+    # We just test the last progress line which should indicate a 100% complete download.
+    # We control progress bar width (5 dots), size (1KB) and total time downloading (fake 1.137s).
+    self.assertEqual('100% ..... 1 KB 1.137s\n', stream.getvalue().split('\r')[-1])
+
 
 class FetcherRedirectTest(unittest.TestCase):
   # NB(Eric Ayers): Using class variables like this seems horrible, but I can't figure out a better