diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 85fef794f..326676ba1 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -13,11 +13,18 @@ from compose.utils import split_buffer class LogPrinter(object): """Print logs from many containers to a single output stream.""" - def __init__(self, containers, output=sys.stdout, monochrome=False, cascade_stop=False): + def __init__(self, + containers, + output=sys.stdout, + monochrome=False, + cascade_stop=False, + log_args=None): + log_args = log_args or {} self.containers = containers self.output = utils.get_output_stream(output) self.monochrome = monochrome self.cascade_stop = cascade_stop + self.log_args = log_args def run(self): if not self.containers: @@ -41,7 +48,7 @@ class LogPrinter(object): for color_func, container in zip(color_funcs, self.containers): generator_func = get_log_generator(container) prefix = color_func(build_log_prefix(container, prefix_width)) - yield generator_func(container, prefix, color_func) + yield generator_func(container, prefix, color_func, self.log_args) def build_log_prefix(container, prefix_width): @@ -64,28 +71,30 @@ def get_log_generator(container): return build_no_log_generator -def build_no_log_generator(container, prefix, color_func): +def build_no_log_generator(container, prefix, color_func, log_args): """Return a generator that prints a warning about logs and waits for container to exit. """ yield "{} WARNING: no logs are available with the '{}' log driver\n".format( prefix, container.log_driver) - yield color_func(wait_on_exit(container)) + if log_args.get('follow'): + yield color_func(wait_on_exit(container)) -def build_log_generator(container, prefix, color_func): +def build_log_generator(container, prefix, color_func, log_args): # if the container doesn't have a log_stream we need to attach to container # before log printer starts running if container.log_stream is None: - stream = container.attach(stdout=True, stderr=True, stream=True, logs=True) + stream = container.logs(stdout=True, stderr=True, stream=True, **log_args) line_generator = split_buffer(stream) else: line_generator = split_buffer(container.log_stream) for line in line_generator: yield prefix + line - yield color_func(wait_on_exit(container)) + if log_args.get('follow'): + yield color_func(wait_on_exit(container)) def wait_on_exit(container): diff --git a/compose/cli/main.py b/compose/cli/main.py index 0a917720f..be33ce523 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -380,13 +380,28 @@ class TopLevelCommand(DocoptCommand): Usage: logs [options] [SERVICE...] Options: - --no-color Produce monochrome output. + --no-color Produce monochrome output. + -f, --follow Follow log output. + -t, --timestamps Show timestamps. + --tail="all" Number of lines to show from the end of the logs + for each container. """ containers = project.containers(service_names=options['SERVICE'], stopped=True) monochrome = options['--no-color'] + tail = options['--tail'] + if tail is not None: + if tail.isdigit(): + tail = int(tail) + elif tail != 'all': + raise UserError("tail flag must be all or a number") + log_args = { + 'follow': options['--follow'], + 'tail': tail, + 'timestamps': options['--timestamps'] + } print("Attaching to", list_containers(containers)) - LogPrinter(containers, monochrome=monochrome).run() + LogPrinter(containers, monochrome=monochrome, log_args=log_args).run() def pause(self, project, options): """ @@ -712,7 +727,8 @@ class TopLevelCommand(DocoptCommand): if detached: return - log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop) + log_args = {'follow': True} + log_printer = build_log_printer(to_attach, service_names, monochrome, cascade_stop, log_args) print("Attaching to", list_containers(log_printer.containers)) log_printer.run() @@ -810,13 +826,13 @@ def run_one_off_container(container_options, project, service, options): sys.exit(exit_code) -def build_log_printer(containers, service_names, monochrome, cascade_stop): +def build_log_printer(containers, service_names, monochrome, cascade_stop, log_args): if service_names: containers = [ container for container in containers if container.service in service_names ] - return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop) + return LogPrinter(containers, monochrome=monochrome, cascade_stop=cascade_stop, log_args=log_args) @contextlib.contextmanager diff --git a/docs/reference/logs.md b/docs/reference/logs.md index 5b241ea70..745d24f7f 100644 --- a/docs/reference/logs.md +++ b/docs/reference/logs.md @@ -15,7 +15,11 @@ parent = "smn_compose_cli" Usage: logs [options] [SERVICE...] Options: ---no-color Produce monochrome output. +--no-color Produce monochrome output. +-f, --follow Follow log output +-t, --timestamps Show timestamps +--tail Number of lines to show from the end of the logs + for each container. ``` Displays log output from services. diff --git a/requirements.txt b/requirements.txt index e25386d24..b31840c85 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ PyYAML==3.11 cached-property==1.2.0 -docker-py==1.7.2 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 +git+https://github.com/docker/docker-py.git@81d8caaf36159bf1accd86eab2e157bf8dd071a9#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 2b61898e7..24682125e 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -398,6 +398,8 @@ class CLITestCase(DockerClientTestCase): assert 'simple_1 | simple' in result.stdout assert 'another_1 | another' in result.stdout + assert 'simple_1 exited with code 0' in result.stdout + assert 'another_1 exited with code 0' in result.stdout @v2_only() def test_up(self): @@ -1159,6 +1161,42 @@ class CLITestCase(DockerClientTestCase): def test_logs_invalid_service_name(self): self.dispatch(['logs', 'madeupname'], returncode=1) + def test_logs_follow(self): + self.base_dir = 'tests/fixtures/echo-services' + self.dispatch(['up', '-d'], None) + + result = self.dispatch(['logs', '-f']) + + assert result.stdout.count('\n') == 5 + assert 'simple' in result.stdout + assert 'another' in result.stdout + assert 'exited with code 0' in result.stdout + + def test_logs_unfollow(self): + self.base_dir = 'tests/fixtures/logs-composefile' + self.dispatch(['up', '-d'], None) + + result = self.dispatch(['logs']) + + assert result.stdout.count('\n') >= 1 + assert 'exited with code 0' not in result.stdout + + def test_logs_timestamps(self): + self.base_dir = 'tests/fixtures/echo-services' + self.dispatch(['up', '-d'], None) + + result = self.dispatch(['logs', '-f', '-t'], None) + + self.assertRegexpMatches(result.stdout, '(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})') + + def test_logs_tail(self): + self.base_dir = 'tests/fixtures/logs-tail-composefile' + self.dispatch(['up'], None) + + result = self.dispatch(['logs', '--tail', '2'], None) + + assert result.stdout.count('\n') == 3 + def test_kill(self): self.dispatch(['up', '-d'], None) service = self.project.get_service('simple') diff --git a/tests/fixtures/logs-composefile/docker-compose.yml b/tests/fixtures/logs-composefile/docker-compose.yml new file mode 100644 index 000000000..0af9d805c --- /dev/null +++ b/tests/fixtures/logs-composefile/docker-compose.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: sh -c "echo hello && sleep 200" +another: + image: busybox:latest + command: sh -c "echo test" diff --git a/tests/fixtures/logs-tail-composefile/docker-compose.yml b/tests/fixtures/logs-tail-composefile/docker-compose.yml new file mode 100644 index 000000000..80d8feaec --- /dev/null +++ b/tests/fixtures/logs-tail-composefile/docker-compose.yml @@ -0,0 +1,3 @@ +simple: + image: busybox:latest + command: sh -c "echo a && echo b && echo c && echo d" diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py index 5b04226cf..54fef0b23 100644 --- a/tests/unit/cli/log_printer_test.py +++ b/tests/unit/cli/log_printer_test.py @@ -17,7 +17,7 @@ def build_mock_container(reader): name_without_project='web_1', has_api_logs=True, log_stream=None, - attach=reader, + logs=reader, wait=mock.Mock(return_value=0), ) @@ -39,7 +39,7 @@ def mock_container(): class TestLogPrinter(object): def test_single_container(self, output_stream, mock_container): - LogPrinter([mock_container], output=output_stream).run() + LogPrinter([mock_container], output=output_stream, log_args={'follow': True}).run() output = output_stream.getvalue() assert 'hello' in output @@ -47,6 +47,15 @@ class TestLogPrinter(object): # Call count is 2 lines + "container exited line" assert output_stream.flush.call_count == 3 + def test_single_container_without_stream(self, output_stream, mock_container): + LogPrinter([mock_container], output=output_stream).run() + + output = output_stream.getvalue() + assert 'hello' in output + assert 'world' in output + # Call count is 2 lines + assert output_stream.flush.call_count == 2 + def test_monochrome(self, output_stream, mock_container): LogPrinter([mock_container], output=output_stream, monochrome=True).run() assert '\033[' not in output_stream.getvalue() @@ -86,3 +95,4 @@ class TestLogPrinter(object): output = output_stream.getvalue() assert "WARNING: no logs are available with the 'none' log driver\n" in output + assert "exited with code" not in output diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py index fd6c50028..e7c52003e 100644 --- a/tests/unit/cli/main_test.py +++ b/tests/unit/cli/main_test.py @@ -33,7 +33,7 @@ class CLIMainTestCase(unittest.TestCase): mock_container('another', 1), ] service_names = ['web', 'db'] - log_printer = build_log_printer(containers, service_names, True, False) + log_printer = build_log_printer(containers, service_names, True, False, {'follow': True}) self.assertEqual(log_printer.containers, containers[:3]) def test_build_log_printer_all_services(self): @@ -43,7 +43,7 @@ class CLIMainTestCase(unittest.TestCase): mock_container('other', 1), ] service_names = [] - log_printer = build_log_printer(containers, service_names, True, False) + log_printer = build_log_printer(containers, service_names, True, False, {'follow': True}) self.assertEqual(log_printer.containers, containers)