Skip to content

Commit e6d0127

Browse files
committed
Add failing tests.
1 parent 169eebd commit e6d0127

File tree

5 files changed

+220
-5
lines changed

5 files changed

+220
-5
lines changed

src/pip/_internal/utils/logging.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
_log_state.indentation = 0
2727

2828

29+
class BrokenStdoutLoggingError(Exception):
30+
"""
31+
Raised if BrokenPipeError occurs for the stdout stream while logging.
32+
"""
33+
pass
34+
35+
2936
@contextlib.contextmanager
3037
def indent_log(num=2):
3138
"""

src/pip/_internal/utils/misc.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,13 @@ def captured_stdout():
864864
return captured_output('stdout')
865865

866866

867+
def captured_stderr():
868+
"""
869+
See captured_stdout().
870+
"""
871+
return captured_output('stderr')
872+
873+
867874
class cached_property(object):
868875
"""A property that is only computed once per instance and then replaces
869876
itself with an ordinary attribute. Deleting the attribute resets the
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import subprocess
2+
import sys
3+
4+
import pytest
5+
6+
from pip._internal.utils.compat import WINDOWS
7+
8+
if sys.version_info < (3, 6):
9+
_BROKEN_STDOUT_RETURN_CODE = 1
10+
else:
11+
# The new exit status was added in Python 3.6 as a result of:
12+
# https://bugs.python.org/issue5319
13+
_BROKEN_STDOUT_RETURN_CODE = 120
14+
15+
16+
def setup_broken_stdout_test(args, deprecated_python):
17+
proc = subprocess.Popen(
18+
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
19+
)
20+
# Call close() on stdout to cause a broken pipe.
21+
proc.stdout.close()
22+
# This line causes a timeout on Windows.
23+
returncode = proc.wait()
24+
stderr = proc.stderr.read().decode('utf-8')
25+
26+
expected_msg = 'ERROR: Pipe to stdout was broken'
27+
if deprecated_python:
28+
assert expected_msg in stderr
29+
else:
30+
assert stderr.startswith(expected_msg)
31+
32+
return stderr, returncode
33+
34+
35+
@pytest.mark.skipif(WINDOWS, reason="test times out on Windows")
36+
def test_broken_stdout_pipe(deprecated_python):
37+
"""
38+
Test a broken pipe to stdout.
39+
"""
40+
stderr, returncode = setup_broken_stdout_test(
41+
['pip', 'list'], deprecated_python=deprecated_python,
42+
)
43+
44+
# Check that no traceback occurs.
45+
assert 'raise BrokenStdoutLoggingError()' not in stderr
46+
assert stderr.count('Traceback') == 0
47+
48+
assert returncode == _BROKEN_STDOUT_RETURN_CODE
49+
50+
51+
@pytest.mark.skipif(WINDOWS, reason="test times out on Windows")
52+
def test_broken_stdout_pipe__verbose(deprecated_python):
53+
"""
54+
Test a broken pipe to stdout with verbose logging enabled.
55+
"""
56+
stderr, returncode = setup_broken_stdout_test(
57+
['pip', '-v', 'list'], deprecated_python=deprecated_python,
58+
)
59+
60+
# Check that a traceback occurs and that it occurs at most once.
61+
# We permit up to two because the exception can be chained.
62+
assert 'raise BrokenStdoutLoggingError()' in stderr
63+
assert 1 <= stderr.count('Traceback') <= 2
64+
65+
assert returncode == _BROKEN_STDOUT_RETURN_CODE

tests/unit/test_base_command.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
import time
44

55
from pip._internal.cli.base_command import Command
6+
from pip._internal.utils.logging import BrokenStdoutLoggingError
67

78

89
class FakeCommand(Command):
910
name = 'fake'
1011
summary = name
1112

12-
def __init__(self, error=False):
13-
self.error = error
13+
def __init__(self, run_func=None, error=False):
14+
if error:
15+
def run_func():
16+
raise SystemExit(1)
17+
18+
self.run_func = run_func
1419
super(FakeCommand, self).__init__()
1520

1621
def main(self, args):
@@ -19,8 +24,8 @@ def main(self, args):
1924

2025
def run(self, options, args):
2126
logging.getLogger("pip.tests").info("fake")
22-
if self.error:
23-
raise SystemExit(1)
27+
if self.run_func:
28+
return self.run_func()
2429

2530

2631
class FakeCommandWithUnicode(FakeCommand):
@@ -34,6 +39,40 @@ def run(self, options, args):
3439
)
3540

3641

42+
class TestCommand(object):
43+
44+
def call_main(self, capsys, args):
45+
"""
46+
Call command.main(), and return the command's stderr.
47+
"""
48+
def raise_broken_stdout():
49+
raise BrokenStdoutLoggingError()
50+
51+
cmd = FakeCommand(run_func=raise_broken_stdout)
52+
status = cmd.main(args)
53+
assert status == 1
54+
stderr = capsys.readouterr().err
55+
56+
return stderr
57+
58+
def test_raise_broken_stdout(self, capsys):
59+
"""
60+
Test raising BrokenStdoutLoggingError.
61+
"""
62+
stderr = self.call_main(capsys, [])
63+
64+
assert stderr.rstrip() == 'ERROR: Pipe to stdout was broken'
65+
66+
def test_raise_broken_stdout__debug_logging(self, capsys):
67+
"""
68+
Test raising BrokenStdoutLoggingError with debug logging enabled.
69+
"""
70+
stderr = self.call_main(capsys, ['-v'])
71+
72+
assert 'ERROR: Pipe to stdout was broken' in stderr
73+
assert 'Traceback (most recent call last):' in stderr
74+
75+
3776
class Test_base_command_logging(object):
3877
"""
3978
Test `pip.base_command.Command` setting up logging consumers based on

tests/unit/test_logging.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,31 @@
1+
import errno
12
import logging
23
import os
34
import time
45

5-
from pip._internal.utils.logging import IndentingFormatter
6+
import pytest
7+
from mock import patch
8+
from pip._vendor.six import PY2
9+
10+
from pip._internal.utils.logging import (
11+
BrokenStdoutLoggingError, ColorizedStreamHandler, IndentingFormatter,
12+
)
13+
from pip._internal.utils.misc import captured_stderr, captured_stdout
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
# This is a Python 2/3 compatibility helper.
19+
def _make_broken_pipe_error():
20+
"""
21+
Return an exception object representing a broken pipe.
22+
"""
23+
if PY2:
24+
# This is one way a broken pipe error can show up in Python 2
25+
# (a non-Windows example in this case).
26+
return IOError(errno.EPIPE, 'Broken pipe')
27+
28+
return BrokenPipeError() # noqa: F821
629

730

831
class TestIndentingFormatter(object):
@@ -43,3 +66,77 @@ def test_format_with_timestamp(self, tmpdir):
4366
f = IndentingFormatter(fmt="%(message)s", add_timestamp=True)
4467
expected = '2019-01-17T06:00:37 hello\n2019-01-17T06:00:37 world'
4568
assert f.format(record) == expected
69+
70+
71+
class TestColorizedStreamHandler(object):
72+
73+
def _make_log_record(self):
74+
attrs = {
75+
'msg': 'my error',
76+
}
77+
record = logging.makeLogRecord(attrs)
78+
79+
return record
80+
81+
def test_broken_pipe_in_stderr_flush(self):
82+
"""
83+
Test sys.stderr.flush() raising BrokenPipeError.
84+
85+
This error should _not_ trigger an error in the logging framework.
86+
"""
87+
record = self._make_log_record()
88+
89+
with captured_stderr() as stderr:
90+
handler = ColorizedStreamHandler(stream=stderr)
91+
with patch('sys.stderr.flush') as mock_flush:
92+
mock_flush.side_effect = _make_broken_pipe_error()
93+
# The emit() call raises no exception.
94+
handler.emit(record)
95+
96+
err_text = stderr.getvalue()
97+
98+
assert err_text.startswith('my error')
99+
# Check that the logging framework tried to log the exception.
100+
if PY2:
101+
assert 'IOError: [Errno 32] Broken pipe' in err_text
102+
assert 'Logged from file' in err_text
103+
else:
104+
assert 'Logging error' in err_text
105+
assert 'BrokenPipeError' in err_text
106+
assert "Message: 'my error'" in err_text
107+
108+
def test_broken_pipe_in_stdout_write(self):
109+
"""
110+
Test sys.stdout.write() raising BrokenPipeError.
111+
112+
This error _should_ trigger an error in the logging framework.
113+
"""
114+
record = self._make_log_record()
115+
116+
with captured_stdout() as stdout:
117+
handler = ColorizedStreamHandler(stream=stdout)
118+
with patch('sys.stdout.write') as mock_write:
119+
mock_write.side_effect = _make_broken_pipe_error()
120+
with pytest.raises(BrokenStdoutLoggingError):
121+
handler.emit(record)
122+
123+
def test_broken_pipe_in_stdout_flush(self):
124+
"""
125+
Test sys.stdout.flush() raising BrokenPipeError.
126+
127+
This error _should_ trigger an error in the logging framework.
128+
"""
129+
record = self._make_log_record()
130+
131+
with captured_stdout() as stdout:
132+
handler = ColorizedStreamHandler(stream=stdout)
133+
with patch('sys.stdout.flush') as mock_flush:
134+
mock_flush.side_effect = _make_broken_pipe_error()
135+
with pytest.raises(BrokenStdoutLoggingError):
136+
handler.emit(record)
137+
138+
output = stdout.getvalue()
139+
140+
# Sanity check that the log record was written, since flush() happens
141+
# after write().
142+
assert output.startswith('my error')

0 commit comments

Comments
 (0)