Skip to content

Commit 1599d65

Browse files
authored
Merge pull request #6312 from cjerdonek/subprocess-tests-and-spinner-fixes
Test call_subprocess more thoroughly; fix spinner edge cases
2 parents 293c91e + fe79372 commit 1599d65

File tree

3 files changed

+240
-20
lines changed

3 files changed

+240
-20
lines changed

news/6312.bugfix

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The spinner no longer displays a completion message after subprocess calls
2+
not needing a spinner. It also no longer incorrectly reports an error after
3+
certain subprocess calls to Git that succeeded.

src/pip/_internal/utils/misc.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,10 @@ def call_subprocess(
705705
stdout = None
706706
else:
707707
stdout = subprocess.PIPE
708+
709+
# Only use the spinner when we're capturing stdout and we have one.
710+
use_spinner = not show_stdout and spinner is not None
711+
708712
if command_desc is None:
709713
command_desc = format_command_args(cmd)
710714

@@ -738,19 +742,22 @@ def call_subprocess(
738742
logger.debug(line)
739743
else:
740744
# Update the spinner
741-
if spinner is not None:
745+
if use_spinner:
742746
spinner.spin()
743747
try:
744748
proc.wait()
745749
finally:
746750
if proc.stdout:
747751
proc.stdout.close()
748-
if spinner is not None:
749-
if proc.returncode:
752+
proc_had_error = (
753+
proc.returncode and proc.returncode not in extra_ok_returncodes
754+
)
755+
if use_spinner:
756+
if proc_had_error:
750757
spinner.finish("error")
751758
else:
752759
spinner.finish("done")
753-
if proc.returncode and proc.returncode not in extra_ok_returncodes:
760+
if proc_had_error:
754761
if on_returncode == 'raise':
755762
if (logger.getEffectiveLevel() > std_logging.DEBUG and
756763
not show_stdout):

tests/unit/test_utils.py

+226-16
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77
import codecs
88
import itertools
9+
import logging
910
import os
1011
import shutil
1112
import stat
@@ -33,6 +34,7 @@
3334
)
3435
from pip._internal.utils.packaging import check_dist_requires_python
3536
from pip._internal.utils.temp_dir import AdjacentTempDirectory, TempDirectory
37+
from pip._internal.utils.ui import SpinnerInterface
3638

3739

3840
class Tests_EgglinkPath:
@@ -750,28 +752,236 @@ def test_format_command_args(args, expected):
750752
assert actual == expected
751753

752754

753-
def test_call_subprocess_works__no_keyword_arguments():
754-
result = call_subprocess(
755-
[sys.executable, '-c', 'print("Hello")'],
756-
)
757-
assert result.rstrip() == 'Hello'
755+
class FakeSpinner(SpinnerInterface):
758756

757+
def __init__(self):
758+
self.spin_count = 0
759+
self.final_status = None
759760

760-
def test_call_subprocess_works__show_stdout_true():
761-
result = call_subprocess(
762-
[sys.executable, '-c', 'print("Hello")'],
763-
show_stdout=True,
764-
)
765-
assert result is None
761+
def spin(self):
762+
self.spin_count += 1
763+
764+
def finish(self, final_status):
765+
self.final_status = final_status
766+
767+
768+
class TestCallSubprocess(object):
769+
770+
"""
771+
Test call_subprocess().
772+
"""
773+
774+
def check_result(
775+
self, capfd, caplog, log_level, spinner, result, expected,
776+
expected_spinner,
777+
):
778+
"""
779+
Check the result of calling call_subprocess().
780+
781+
:param log_level: the logging level that caplog was set to.
782+
:param spinner: the FakeSpinner object passed to call_subprocess()
783+
to be checked.
784+
:param result: the call_subprocess() return value to be checked.
785+
:param expected: a 3-tuple (expected_proc, expected_out,
786+
expected_records), where
787+
1) `expected_proc` is the expected return value of
788+
call_subprocess() as a list of lines, or None if the return
789+
value is expected to be None;
790+
2) `expected_out` is the expected stdout captured from the
791+
subprocess call, as a list of lines; and
792+
3) `expected_records` is the expected value of
793+
caplog.record_tuples.
794+
:param expected_spinner: a 2-tuple of the spinner's expected
795+
(spin_count, final_status).
796+
"""
797+
expected_proc, expected_out, expected_records = expected
798+
799+
if expected_proc is None:
800+
assert result is expected_proc
801+
else:
802+
assert result.splitlines() == expected_proc
803+
804+
captured = capfd.readouterr()
805+
stdout, stderr = captured.out, captured.err
806+
807+
assert stdout.splitlines() == expected_out
808+
assert stderr == ''
809+
810+
records = caplog.record_tuples
811+
if len(records) != len(expected_records):
812+
raise RuntimeError('{} != {}'.format(records, expected_records))
813+
814+
for record, expected_record in zip(records, expected_records):
815+
# Check the logger_name and log level parts exactly.
816+
assert record[:2] == expected_record[:2]
817+
# For the message portion, check only a substring. Also, we
818+
# can't use startswith() since the order of stdout and stderr
819+
# isn't guaranteed in cases where stderr is also present.
820+
# For example, we observed the stderr lines coming before stdout
821+
# in CI for PyPy 2.7 even though stdout happens first
822+
# chronologically.
823+
assert expected_record[2] in record[2]
824+
825+
assert (spinner.spin_count, spinner.final_status) == expected_spinner
826+
827+
def prepare_call(self, caplog, log_level, command=None):
828+
if command is None:
829+
command = 'print("Hello"); print("world")'
830+
831+
caplog.set_level(log_level)
832+
spinner = FakeSpinner()
833+
args = [sys.executable, '-c', command]
834+
835+
return (args, spinner)
836+
837+
def test_debug_logging(self, capfd, caplog):
838+
"""
839+
Test DEBUG logging (and without passing show_stdout=True).
840+
"""
841+
log_level = logging.DEBUG
842+
args, spinner = self.prepare_call(caplog, log_level)
843+
result = call_subprocess(args, spinner=spinner)
844+
845+
expected = (['Hello', 'world'], [], [
846+
('pip._internal.utils.misc', 10, 'Running command '),
847+
('pip._internal.utils.misc', 10, 'Hello'),
848+
('pip._internal.utils.misc', 10, 'world'),
849+
])
850+
# The spinner shouldn't spin in this case since the subprocess
851+
# output is already being logged to the console.
852+
self.check_result(
853+
capfd, caplog, log_level, spinner, result, expected,
854+
expected_spinner=(0, 'done'),
855+
)
766856

857+
def test_info_logging(self, capfd, caplog):
858+
"""
859+
Test INFO logging (and without passing show_stdout=True).
860+
"""
861+
log_level = logging.INFO
862+
args, spinner = self.prepare_call(caplog, log_level)
863+
result = call_subprocess(args, spinner=spinner)
864+
865+
expected = (['Hello', 'world'], [], [])
866+
# The spinner should spin twice in this case since the subprocess
867+
# output isn't being written to the console.
868+
self.check_result(
869+
capfd, caplog, log_level, spinner, result, expected,
870+
expected_spinner=(2, 'done'),
871+
)
767872

768-
def test_call_subprocess_closes_stdin():
769-
with pytest.raises(InstallationError):
770-
call_subprocess(
771-
[sys.executable, '-c', 'input()'],
772-
show_stdout=True,
873+
def test_info_logging__subprocess_error(self, capfd, caplog):
874+
"""
875+
Test INFO logging of a subprocess with an error (and without passing
876+
show_stdout=True).
877+
"""
878+
log_level = logging.INFO
879+
command = 'print("Hello"); print("world"); exit("fail")'
880+
args, spinner = self.prepare_call(caplog, log_level, command=command)
881+
882+
with pytest.raises(InstallationError):
883+
call_subprocess(args, spinner=spinner)
884+
result = None
885+
886+
expected = (None, [], [
887+
('pip._internal.utils.misc', 20, 'Complete output from command '),
888+
# The "failed" portion is later on in this "Hello" string.
889+
('pip._internal.utils.misc', 20, 'Hello'),
890+
])
891+
# The spinner should spin three times in this case since the
892+
# subprocess output isn't being written to the console.
893+
self.check_result(
894+
capfd, caplog, log_level, spinner, result, expected,
895+
expected_spinner=(3, 'error'),
773896
)
774897

898+
# Do some further checking on the captured log records to confirm
899+
# that the subprocess output was logged.
900+
last_record = caplog.record_tuples[-1]
901+
last_message = last_record[2]
902+
lines = last_message.splitlines()
903+
904+
# We have to sort before comparing the lines because we can't
905+
# guarantee the order in which stdout and stderr will appear.
906+
# For example, we observed the stderr lines coming before stdout
907+
# in CI for PyPy 2.7 even though stdout happens first chronologically.
908+
assert sorted(lines) == [
909+
'----------------------------------------',
910+
'Hello',
911+
'fail',
912+
'world',
913+
], 'lines: {}'.format(lines) # Show the full output on failure.
914+
915+
def test_info_logging_with_show_stdout_true(self, capfd, caplog):
916+
"""
917+
Test INFO logging with show_stdout=True.
918+
"""
919+
log_level = logging.INFO
920+
args, spinner = self.prepare_call(caplog, log_level)
921+
result = call_subprocess(args, spinner=spinner, show_stdout=True)
922+
923+
expected = (None, ['Hello', 'world'], [])
924+
# The spinner shouldn't spin in this case since the subprocess
925+
# output is already being written to the console.
926+
self.check_result(
927+
capfd, caplog, log_level, spinner, result, expected,
928+
expected_spinner=(0, None),
929+
)
930+
931+
@pytest.mark.parametrize((
932+
'exit_status', 'show_stdout', 'extra_ok_returncodes', 'log_level',
933+
'expected'),
934+
[
935+
(0, False, None, logging.INFO, (None, 'done', 2)),
936+
# Test some cases that should result in show_spinner false.
937+
(0, False, None, logging.DEBUG, (None, 'done', 0)),
938+
# Test show_stdout=True.
939+
(0, True, None, logging.DEBUG, (None, None, 0)),
940+
(0, True, None, logging.INFO, (None, None, 0)),
941+
(0, True, None, logging.WARNING, (None, None, 0)),
942+
# Test a non-zero exit status.
943+
(3, False, None, logging.INFO, (InstallationError, 'error', 2)),
944+
# Test a non-zero exit status also in extra_ok_returncodes.
945+
(3, False, (3, ), logging.INFO, (None, 'done', 2)),
946+
])
947+
def test_spinner_finish(
948+
self, exit_status, show_stdout, extra_ok_returncodes, log_level,
949+
caplog, expected,
950+
):
951+
"""
952+
Test that the spinner finishes correctly.
953+
"""
954+
expected_exc_type = expected[0]
955+
expected_final_status = expected[1]
956+
expected_spin_count = expected[2]
957+
958+
command = (
959+
'print("Hello"); print("world"); exit({})'.format(exit_status)
960+
)
961+
args, spinner = self.prepare_call(caplog, log_level, command=command)
962+
try:
963+
call_subprocess(
964+
args,
965+
show_stdout=show_stdout,
966+
extra_ok_returncodes=extra_ok_returncodes,
967+
spinner=spinner,
968+
)
969+
except Exception as exc:
970+
exc_type = type(exc)
971+
else:
972+
exc_type = None
973+
974+
assert exc_type == expected_exc_type
975+
assert spinner.final_status == expected_final_status
976+
assert spinner.spin_count == expected_spin_count
977+
978+
def test_closes_stdin(self):
979+
with pytest.raises(InstallationError):
980+
call_subprocess(
981+
[sys.executable, '-c', 'input()'],
982+
show_stdout=True,
983+
)
984+
775985

776986
@pytest.mark.parametrize('args, expected', [
777987
# Test without subdir.

0 commit comments

Comments
 (0)