Flesh out missing test coverage for execute.py.

This commit is contained in:
Dan Helfman 2020-05-12 15:49:49 -07:00
parent 8b91c01a4c
commit 15ea70a71b
3 changed files with 266 additions and 23 deletions

View file

@ -47,6 +47,9 @@ def log_outputs(processes, exclude_stdouts, output_log_level, error_on_warnings)
For simplicity, it's assumed that the output buffer for each process is its stdout. But if any For simplicity, it's assumed that the output buffer for each process is its stdout. But if any
stdouts are given to exclude, then for any matching processes, log from their stderr instead. stdouts are given to exclude, then for any matching processes, log from their stderr instead.
Note that stdout for a process can be None if output is intentionally not captured. In which
case it won't be logged.
''' '''
# Map from output buffer to sequence of last lines. # Map from output buffer to sequence of last lines.
buffer_last_lines = collections.defaultdict(list) buffer_last_lines = collections.defaultdict(list)
@ -56,7 +59,11 @@ def log_outputs(processes, exclude_stdouts, output_log_level, error_on_warnings)
if process.stdout or process.stderr if process.stdout or process.stderr
] ]
# Log output for each process until they all exit.
while True: while True:
if not output_buffers:
break
(ready_buffers, _, _) = select.select(output_buffers, [], []) (ready_buffers, _, _) = select.select(output_buffers, [], [])
for ready_buffer in ready_buffers: for ready_buffer in ready_buffers:
@ -76,6 +83,7 @@ def log_outputs(processes, exclude_stdouts, output_log_level, error_on_warnings)
if all(process.poll() is not None for process in processes): if all(process.poll() is not None for process in processes):
break break
# Consume any remaining output that we missed (if any).
for process in processes: for process in processes:
output_buffer = output_buffer_for_process(process, exclude_stdouts) output_buffer = output_buffer_for_process(process, exclude_stdouts)
@ -87,8 +95,9 @@ def log_outputs(processes, exclude_stdouts, output_log_level, error_on_warnings)
if remaining_output: # pragma: no cover if remaining_output: # pragma: no cover
logger.log(output_log_level, remaining_output) logger.log(output_log_level, remaining_output)
# If any process errored, then raise accordingly.
for process in processes: for process in processes:
exit_code = process.poll() exit_code = process.wait()
if exit_code_indicates_error(exit_code, error_on_warnings): if exit_code_indicates_error(exit_code, error_on_warnings):
# If an error occurs, include its output in the raised exception so that we don't # If an error occurs, include its output in the raised exception so that we don't
@ -116,6 +125,9 @@ def log_command(full_command, input_file, output_file):
) )
# An sentinel passed as an output file to execute_command() to indicate that the command's output
# should be allowed to flow through to stdout without being captured for logging. Useful for
# commands with interactive prompts or those that mess directly with the console.
DO_NOT_CAPTURE = object() DO_NOT_CAPTURE = object()
@ -147,15 +159,16 @@ def execute_command(
log_command(full_command, input_file, output_file) log_command(full_command, input_file, output_file)
environment = {**os.environ, **extra_environment} if extra_environment else None environment = {**os.environ, **extra_environment} if extra_environment else None
do_not_capture = bool(output_file is DO_NOT_CAPTURE) do_not_capture = bool(output_file is DO_NOT_CAPTURE)
command = ' '.join(full_command) if shell else full_command
if output_log_level is None: if output_log_level is None:
output = subprocess.check_output( output = subprocess.check_output(
full_command, shell=shell, env=environment, cwd=working_directory command, shell=shell, env=environment, cwd=working_directory
) )
return output.decode() if output is not None else None return output.decode() if output is not None else None
process = subprocess.Popen( process = subprocess.Popen(
' '.join(full_command) if shell else full_command, command,
stdin=input_file, stdin=input_file,
stdout=None if do_not_capture else (output_file or subprocess.PIPE), stdout=None if do_not_capture else (output_file or subprocess.PIPE),
stderr=None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT), stderr=None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT),
@ -166,14 +179,6 @@ def execute_command(
if not run_to_completion: if not run_to_completion:
return process return process
if do_not_capture:
exit_code = process.wait()
if exit_code_indicates_error(exit_code, error_on_warnings):
raise subprocess.CalledProcessError(exit_code, command_for_process(process))
return None
log_outputs((process,), (input_file, output_file), output_log_level, error_on_warnings) log_outputs((process,), (input_file, output_file), output_log_level, error_on_warnings)
@ -208,10 +213,11 @@ def execute_command_with_processes(
log_command(full_command, input_file, output_file) log_command(full_command, input_file, output_file)
environment = {**os.environ, **extra_environment} if extra_environment else None environment = {**os.environ, **extra_environment} if extra_environment else None
do_not_capture = bool(output_file is DO_NOT_CAPTURE) do_not_capture = bool(output_file is DO_NOT_CAPTURE)
command = ' '.join(full_command) if shell else full_command
try: try:
command_process = subprocess.Popen( command_process = subprocess.Popen(
full_command, command,
stdin=input_file, stdin=input_file,
stdout=None if do_not_capture else (output_file or subprocess.PIPE), stdout=None if do_not_capture else (output_file or subprocess.PIPE),
stderr=None stderr=None

View file

@ -17,17 +17,39 @@ def test_log_outputs_logs_each_line_separately():
hi_process, () hi_process, ()
).and_return(hi_process.stdout) ).and_return(hi_process.stdout)
there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE)
flexmock(module).should_receive('output_buffer_for_process').with_args(
there_process, ()
).and_return(there_process.stdout)
module.log_outputs( module.log_outputs(
(hi_process,), exclude_stdouts=(), output_log_level=logging.INFO, error_on_warnings=False (hi_process, there_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
error_on_warnings=False,
) )
def test_log_outputs_skips_logs_for_process_with_none_stdout():
flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'hi').never()
flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'there').once()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
hi_process = subprocess.Popen(['echo', 'hi'], stdout=None)
flexmock(module).should_receive('output_buffer_for_process').with_args(
hi_process, ()
).and_return(hi_process.stdout)
there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE) there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE)
flexmock(module).should_receive('output_buffer_for_process').with_args( flexmock(module).should_receive('output_buffer_for_process').with_args(
there_process, () there_process, ()
).and_return(there_process.stdout) ).and_return(there_process.stdout)
module.log_outputs( module.log_outputs(
(there_process,), exclude_stdouts=(), output_log_level=logging.INFO, error_on_warnings=False (hi_process, there_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
error_on_warnings=False,
) )
@ -48,6 +70,23 @@ def test_log_outputs_includes_error_output_in_exception():
assert error.value.output assert error.value.output
def test_log_outputs_skips_error_output_in_exception_for_process_with_none_stdout():
flexmock(module.logger).should_receive('log')
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
flexmock(module).should_receive('command_for_process').and_return('grep')
process = subprocess.Popen(['grep'], stdout=None)
flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout)
with pytest.raises(subprocess.CalledProcessError) as error:
module.log_outputs(
(process,), exclude_stdouts=(), output_log_level=logging.INFO, error_on_warnings=False
)
assert error.value.returncode == 2
assert not error.value.output
def test_log_outputs_truncates_long_error_output(): def test_log_outputs_truncates_long_error_output():
flexmock(module).ERROR_OUTPUT_MAX_LINE_COUNT = 0 flexmock(module).ERROR_OUTPUT_MAX_LINE_COUNT = 0
flexmock(module.logger).should_receive('log') flexmock(module.logger).should_receive('log')

View file

@ -1,3 +1,5 @@
import subprocess
import pytest import pytest
from flexmock import flexmock from flexmock import flexmock
@ -24,17 +26,34 @@ def test_exit_code_indicates_error_respects_exit_code_and_error_on_warnings(
) )
def output_buffer_for_process_returns_stderr_when_stdout_excluded(): def test_command_for_process_converts_sequence_command_to_string():
process = flexmock(args=['foo', 'bar', 'baz'])
assert module.command_for_process(process) == 'foo bar baz'
def test_command_for_process_passes_through_string_command():
process = flexmock(args='foo bar baz')
assert module.command_for_process(process) == 'foo bar baz'
def test_output_buffer_for_process_returns_stderr_when_stdout_excluded():
stdout = flexmock()
stderr = flexmock()
process = flexmock(stdout=stdout, stderr=stderr)
assert module.output_buffer_for_process(process, exclude_stdouts=[flexmock(), stdout]) == stderr
def test_output_buffer_for_process_returns_stdout_when_not_excluded():
stdout = flexmock() stdout = flexmock()
process = flexmock(stdout=stdout) process = flexmock(stdout=stdout)
module.output_buffer_for_process(process, excluded_stdouts=[flexmock(), stdout]) assert (
module.output_buffer_for_process(process, exclude_stdouts=[flexmock(), flexmock()])
== stdout
def output_buffer_for_process_returns_stdout_when_not_excluded(): )
process = flexmock(stdout=flexmock())
module.output_buffer_for_process(process, excluded_stdouts=[flexmock(), flexmock()])
def test_execute_command_calls_full_command(): def test_execute_command_calls_full_command():
@ -76,6 +95,20 @@ def test_execute_command_calls_full_command_with_output_file():
assert output is None assert output is None
def test_execute_command_calls_full_command_without_capturing_output():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
).and_return(flexmock(wait=lambda: 0)).once()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
flexmock(module).should_receive('log_outputs')
output = module.execute_command(full_command, output_file=module.DO_NOT_CAPTURE)
assert output is None
def test_execute_command_calls_full_command_with_input_file(): def test_execute_command_calls_full_command_with_input_file():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
input_file = flexmock(name='test') input_file = flexmock(name='test')
@ -189,7 +222,7 @@ def test_execute_command_captures_output_with_shell():
expected_output = '[]' expected_output = '[]'
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args( flexmock(module.subprocess).should_receive('check_output').with_args(
full_command, shell=True, env=None, cwd=None 'foo bar', shell=True, env=None, cwd=None
).and_return(flexmock(decode=lambda: expected_output)).once() ).and_return(flexmock(decode=lambda: expected_output)).once()
output = module.execute_command(full_command, output_log_level=None, shell=True) output = module.execute_command(full_command, output_log_level=None, shell=True)
@ -225,3 +258,168 @@ def test_execute_command_captures_output_with_working_directory():
) )
assert output == expected_output assert output == expected_output
def test_execute_command_with_processes_calls_full_command():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(full_command, processes)
assert output is None
def test_execute_command_with_processes_calls_full_command_with_output_file():
full_command = ['foo', 'bar']
processes = (flexmock(),)
output_file = flexmock(name='test')
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=output_file,
stderr=module.subprocess.PIPE,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stderr=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(full_command, processes, output_file=output_file)
assert output is None
def test_execute_command_with_processes_calls_full_command_without_capturing_output():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
).and_return(flexmock(wait=lambda: 0)).once()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(
full_command, processes, output_file=module.DO_NOT_CAPTURE
)
assert output is None
def test_execute_command_with_processes_calls_full_command_with_input_file():
full_command = ['foo', 'bar']
processes = (flexmock(),)
input_file = flexmock(name='test')
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=input_file,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(full_command, processes, input_file=input_file)
assert output is None
def test_execute_command_with_processes_calls_full_command_with_shell():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
' '.join(full_command),
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=True,
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(full_command, processes, shell=True)
assert output is None
def test_execute_command_with_processes_calls_full_command_with_extra_environment():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env={'a': 'b', 'c': 'd'},
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(
full_command, processes, extra_environment={'c': 'd'}
)
assert output is None
def test_execute_command_with_processes_calls_full_command_with_working_directory():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd='/working',
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(
full_command, processes, working_directory='/working'
)
assert output is None
def test_execute_command_with_processes_kills_processes_on_error():
full_command = ['foo', 'bar']
process = flexmock(stdout=flexmock(read=lambda count: None))
process.should_receive('poll')
process.should_receive('kill').once()
processes = (process,)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_raise(subprocess.CalledProcessError(1, full_command, 'error')).once()
flexmock(module).should_receive('log_outputs').never()
with pytest.raises(subprocess.CalledProcessError):
module.execute_command_with_processes(full_command, processes)