blob: 216f77ed45e2d568521a42c69568a8cdbb5b8cd3 [file] [log] [blame]
#! /usr/bin/env python3
#
# Copyright 2021 Google Inc.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met: redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer;
# redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution;
# neither the name of the copyright holders nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import argparse
import copy
import signal
import sys
import unittest
import unittest.mock
parser = argparse.ArgumentParser(
description='''Circular buffer for text output.
To capture the rolling last 25 lines of output from command "command":
command | logroll.py -n 25
While that's running, to see the most recent 25 lines of output without
interrupting "command", send SIGUSR1 to the logroll.py process:
kill -s USR1 ${PID of logroll.py}''')
parser.add_argument('-n', '--lines', default=10, type=int,
help='Maximum number of lines to buffer at a time.')
parser.add_argument('file', nargs='?', default=sys.stdin,
type=argparse.FileType('r', encoding='UTF-8'),
help='File to read from, default is stdin')
args = parser.parse_args()
def dump_lines(lines, idx):
for line in lines[idx:]:
print(line, end='')
for line in lines[:idx]:
print(line, end='')
def dump_and_exit(lines, idx):
dump_lines(lines, idx)
sys.exit(0)
def main(target, incoming):
idx = 0
lines = []
last_idx = target - 1
signal.signal(signal.SIGUSR1, lambda num, frame: dump_lines(lines, idx))
signal.signal(signal.SIGINT, lambda num, frame: dump_and_exit(lines, idx))
for line in incoming:
lines.append(line)
if idx == last_idx:
idx = 0
break
else:
idx += 1
for lines[idx] in incoming:
idx = 0 if idx == last_idx else idx + 1
dump_lines(lines, idx)
if __name__ == '__main__':
main(target=args.lines, incoming=args.file)
# Unit tests #
class CopyingMock(unittest.mock.MagicMock):
def __call__(self, *args, **kwargs):
args = copy.deepcopy(args)
kwargs = copy.deepcopy(kwargs)
return super(CopyingMock, self).__call__(*args, **kwargs)
class TestLogroll(unittest.TestCase):
# Test data.
lines2 = ['First line',
'Second line']
lines3 = ['First line',
'Second line',
'Third line']
lines8 = ['First line',
'Second line',
'Third line',
'Fourth line',
'Fifth line',
'Sixth line',
'Seventh line',
'Eigth line']
# Generator which returns lines like a file object would.
def line_gen(self, lines):
for line in lines:
yield line
# Generator like above, but which simulates a signal midway through.
def signal_line_gen(self, lines, pos, sig_dict, signal):
# Return the first few lines.
for line in lines[:pos]:
yield line
# Simulate receiving the signal.
self.assertIn(signal, sig_dict)
if signal in sig_dict:
# Pas in junk for the num and frame arguments.
sig_dict[signal](None, None)
# Return the remaining lines.
for line in lines[pos:]:
yield line
# Set up a mock of signal.signal to record handlers in a dict.
def mock_signal_dict(self, mock):
signal_dict = {}
def signal_signal(num, action):
signal_dict[num] = action
mock.side_effect = signal_signal
return signal_dict
# Actual test methods.
def test_filling_dump_lines(self):
with unittest.mock.patch('builtins.print') as mock_print:
dump_lines(self.lines2, len(self.lines2))
calls = list([ unittest.mock.call(line, end='') for
line in self.lines2 ])
mock_print.assert_has_calls(calls)
def test_full_dump_lines(self):
with unittest.mock.patch('builtins.print') as mock_print:
dump_lines(self.lines2, 0)
calls = list([ unittest.mock.call(line, end='') for
line in self.lines2 ])
mock_print.assert_has_calls(calls)
def test_offset_dump_lines(self):
with unittest.mock.patch('builtins.print') as mock_print:
dump_lines(self.lines3, 1)
calls = [ unittest.mock.call(self.lines3[1], end=''),
unittest.mock.call(self.lines3[2], end=''),
unittest.mock.call(self.lines3[0], end='') ]
mock_print.assert_has_calls(calls)
def test_dump_and_exit(self):
with unittest.mock.patch('sys.exit') as mock_sys_exit, \
unittest.mock.patch(__name__ + '.dump_lines',
new_callable=CopyingMock) as mock_dump_lines:
idx = 1
dump_and_exit(self.lines3, idx)
mock_dump_lines.assert_called_with(self.lines3, idx)
mock_sys_exit.assert_called_with(0)
def test_filling_main(self):
with unittest.mock.patch('builtins.print') as mock_print:
main(5, self.line_gen(self.lines3))
calls = list([ unittest.mock.call(line, end='') for
line in self.lines3 ])
mock_print.assert_has_calls(calls)
def test_full_main(self):
with unittest.mock.patch('builtins.print') as mock_print:
main(5, self.line_gen(self.lines8))
calls = list([ unittest.mock.call(line, end='') for
line in self.lines8[-5:] ])
mock_print.assert_has_calls(calls)
def test_sigusr1_filling_main(self):
with unittest.mock.patch('signal.signal') as mock_signal, \
unittest.mock.patch(__name__ + '.dump_lines',
new_callable=CopyingMock) as mock_dump_lines:
signal_dict = self.mock_signal_dict(mock_signal)
main(4, self.signal_line_gen(
self.lines8, 3, signal_dict, signal.SIGUSR1))
mock_dump_lines.assert_has_calls([
unittest.mock.call(self.lines8[0:3], 3 % 4),
unittest.mock.call(self.lines8[-4:], len(self.lines8) % 4)
])
def test_sigint_filling_main(self):
with unittest.mock.patch('signal.signal') as mock_signal, \
unittest.mock.patch(__name__ + '.dump_lines',
new_callable=CopyingMock) as mock_dump_lines:
signal_dict = self.mock_signal_dict(mock_signal)
with self.assertRaises(SystemExit):
main(4, self.signal_line_gen(
self.lines8, 3, signal_dict, signal.SIGINT))
mock_dump_lines.assert_has_calls([
unittest.mock.call(self.lines8[0:3], 3 % 4),
])
def test_sigusr1_full_main(self):
with unittest.mock.patch('signal.signal') as mock_signal, \
unittest.mock.patch(__name__ + '.dump_lines',
new_callable=CopyingMock) as mock_dump_lines:
signal_dict = self.mock_signal_dict(mock_signal)
main(4, self.signal_line_gen(
self.lines8, 5, signal_dict, signal.SIGUSR1))
mock_dump_lines.assert_has_calls([
unittest.mock.call(self.lines8[4:5] + self.lines8[1:4], 5 % 4),
unittest.mock.call(self.lines8[-4:], len(self.lines8) % 4)
])
def test_sigint_full_main(self):
with unittest.mock.patch('signal.signal') as mock_signal, \
unittest.mock.patch(__name__ + '.dump_lines',
new_callable=CopyingMock) as mock_dump_lines:
signal_dict = self.mock_signal_dict(mock_signal)
with self.assertRaises(SystemExit):
main(4, self.signal_line_gen(
self.lines8, 5, signal_dict, signal.SIGINT))
mock_dump_lines.assert_has_calls([
unittest.mock.call(self.lines8[4:5] + self.lines8[1:4], 5 % 4),
])