diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c654418..b8ca0285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Enhanced autohooks activate cli to show additional information if a autohooks git hook is already installed [#30](https://github.com/greenbone/autohooks/pull/30) +* Added plugin API for additional info status output + [#39](https://github.com/greenbone/autohooks/pull/39) +* Added plugin API for additional message printing + [#39](https://github.com/greenbone/autohooks/pull/39) ### Changed @@ -40,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#30](https://github.com/greenbone/autohooks/pull/30) * A warning is raised during git hook execution if the current mode is different to the configured mode [#30](https://github.com/greenbone/autohooks/pull/30) +* Improved output formatting [#39](https://github.com/greenbone/autohooks/pull/39) ### Deprecated ### Fixed diff --git a/autohooks/api/__init__.py b/autohooks/api/__init__.py index 0cafa34e..70e54884 100644 --- a/autohooks/api/__init__.py +++ b/autohooks/api/__init__.py @@ -17,8 +17,44 @@ import sys -from autohooks.terminal import ok, fail, error, warning +from autohooks.terminal import Terminal + +__term = None # pylint: disable=invalid-name + +__all__ = [ + 'error', + 'fail', + 'info', + 'ok', + 'out', + 'warning', +] + + +def ok(message: str) -> None: + __term.ok(message) + + +def fail(message: str) -> None: + __term.fail(message) + + +def error(message: str) -> None: + __term.error(message) + + +def warning(message: str) -> None: + __term.warning(message) + + +def info(message: str) -> None: + __term.info(message) def out(message: str): - print(message) + __term.print(message) + + +def _set_terminal(term: Terminal): + global __term # pylint: disable=global-statement, invalid-name + __term = term diff --git a/autohooks/cli/__init__.py b/autohooks/cli/__init__.py index ec4f27e0..647d9d7e 100644 --- a/autohooks/cli/__init__.py +++ b/autohooks/cli/__init__.py @@ -17,6 +17,7 @@ import argparse +from autohooks.terminal import Terminal from autohooks.settings import Mode from autohooks.version import get_version @@ -60,10 +61,11 @@ def main(): if not args.command: parser.print_usage() + term = Terminal() if args.command == 'activate': - install_hooks(args) + install_hooks(term, args) elif args.command == 'check': - check_hooks() + check_hooks(term) if __name__ == "__main__": diff --git a/autohooks/cli/activate.py b/autohooks/cli/activate.py index d1f77580..fc069653 100644 --- a/autohooks/cli/activate.py +++ b/autohooks/cli/activate.py @@ -20,39 +20,39 @@ from autohooks.config import ( load_config_from_pyproject_toml, get_pyproject_toml_path, - AUTOHOOKS_SECTION, ) from autohooks.hooks import PreCommitHook from autohooks.settings import Mode -from autohooks.terminal import ok, warning, info +from autohooks.terminal import Terminal -def install_hooks(args: Namespace) -> None: +def install_hooks(term: Terminal, args: Namespace) -> None: pre_commit_hook = PreCommitHook() pyproject_toml = get_pyproject_toml_path() config = load_config_from_pyproject_toml(pyproject_toml) if pre_commit_hook.exists() and not args.force: - ok( - 'pre-commit hook is already installed at {}.'.format( + term.ok( + 'autohooks pre-commit hook is already installed at {}.'.format( str(pre_commit_hook) ) ) - info( - "Run 'autohooks activate --force' to override the current " - "installed pre-commit hook." - ) - info( - "Run 'autohooks check' to validate the current status of " - "the installed pre-commit hook." - ) + with term.indent(): + term.print() + term.info( + "Run 'autohooks activate --force' to override the current " + "installed pre-commit hook." + ) + term.info( + "Run 'autohooks check' to validate the current status of " + "the installed pre-commit hook." + ) else: if not config.is_autohooks_enabled(): - warning( - 'Warning: autohooks is not enabled in your {} file. Please add ' - 'a "{}" section. Run autohooks check for more details.'.format( - str(pyproject_toml), AUTOHOOKS_SECTION - ) + term.warning( + 'autohooks is not enabled in your {} file. ' + 'Run \'autohooks check\' for more ' + 'details.'.format(str(pyproject_toml)) ) if args.mode: @@ -62,8 +62,8 @@ def install_hooks(args: Namespace) -> None: pre_commit_hook.write(mode=mode) - ok( - 'pre-commit hook installed at {} using {} mode'.format( + term.ok( + 'autohooks pre-commit hook installed at {} using {} mode.'.format( str(pre_commit_hook), str(mode.get_effective_mode()) ) ) diff --git a/autohooks/cli/check.py b/autohooks/cli/check.py index 35bd0c15..ed87a459 100644 --- a/autohooks/cli/check.py +++ b/autohooks/cli/check.py @@ -34,34 +34,47 @@ from autohooks.settings import Mode -from autohooks.terminal import ok, error, warning +from autohooks.terminal import Terminal -def check_hooks() -> None: +def check_hooks(term: Terminal) -> None: pre_commit_hook = PreCommitHook() + hook_mode = pre_commit_hook.read_mode() - check_pre_commit_hook(pre_commit_hook) + check_pre_commit_hook(term, pre_commit_hook, hook_mode) pyproject_toml = get_pyproject_toml_path() - check_config(pyproject_toml, pre_commit_hook.read_mode()) + check_config(term, pyproject_toml, pre_commit_hook, hook_mode) -def check_pre_commit_hook(pre_commit_hook: PreCommitHook) -> None: +def check_pre_commit_hook( + term: Terminal, pre_commit_hook: PreCommitHook, hook_mode: Mode, +) -> None: if pre_commit_hook.exists(): if pre_commit_hook.is_autohooks_pre_commit_hook(): - ok('autohooks pre-commit hook is active.') + term.ok('autohooks pre-commit hook is active.') if pre_commit_hook.is_current_autohooks_pre_commit_hook(): - ok('autohooks pre-commit hook is up-to-date.') + term.ok('autohooks pre-commit hook is up-to-date.') else: - warning( + term.warning( 'autohooks pre-commit hook is outdated. Please run ' '\'autohooks activate --force\' to update your pre-commit ' 'hook.' ) + + hook_mode = pre_commit_hook.read_mode() + if hook_mode == Mode.UNKNOWN: + term.warning( + 'Unknown autohooks mode in {}. Falling back to "{}" ' + 'mode.'.format( + str(pre_commit_hook), + str(hook_mode.get_effective_mode()), + ) + ) else: - error( + term.error( 'autohooks pre-commit hook is not active. But a different ' 'pre-commit hook has been found at {}.'.format( str(pre_commit_hook) @@ -69,39 +82,66 @@ def check_pre_commit_hook(pre_commit_hook: PreCommitHook) -> None: ) else: - error( + term.error( 'autohooks pre-commit hook not active. Please run \'autohooks ' 'activate\'.' ) -def check_config(pyproject_toml: Path, hook_mode: Mode) -> None: +def check_config( + term: Terminal, + pyproject_toml: Path, + pre_commit_hook: PreCommitHook, + hook_mode: Mode, +) -> None: if not pyproject_toml.exists(): - error( + term.error( 'Missing {} file. Please add a pyproject.toml file and include ' 'a "{}" section.'.format(str(pyproject_toml), AUTOHOOKS_SECTION) ) else: config = load_config_from_pyproject_toml(pyproject_toml) if not config.is_autohooks_enabled(): - error( + term.error( 'autohooks is not enabled in your {} file. Please add ' 'a "{}" section.'.format(str(pyproject_toml), AUTOHOOKS_SECTION) ) else: - if config.get_mode() != hook_mode: - warning( - 'autohooks mode in pre-commit hook ("{}") differs from ' - 'mode in {} file ("{}")'.format( + config_mode = config.get_mode() + if config_mode == Mode.UNDEFINED: + term.warning( + 'autohooks mode is not defined in {}.'.format( + str(pyproject_toml) + ) + ) + elif config_mode == Mode.UNKNOWN: + term.warning( + 'Unknown autohooks mode in {}.'.format(str(pyproject_toml)) + ) + + if ( + config_mode.get_effective_mode() + != hook_mode.get_effective_mode() + ): + term.warning( + 'autohooks mode "{}" in pre-commit hook {} differs from ' + 'mode "{}" in {}.'.format( str(hook_mode), + str(pre_commit_hook), + str(config_mode), str(pyproject_toml), - str(config.get_mode()), ) ) + term.info( + 'Using autohooks mode "{}".'.format( + str(hook_mode.get_effective_mode()) + ) + ) + plugins = config.get_pre_commit_script_names() if not plugins: - error( + term.error( 'No autohooks plugin is activated in {} for your pre ' 'commit hook. Please add a ' '"pre-commit = [plugin1, plugin2]" ' @@ -113,7 +153,7 @@ def check_config(pyproject_toml: Path, hook_mode: Mode) -> None: try: plugin = load_plugin(name) if not has_precommit_function(plugin): - error( + term.error( 'Plugin "{}" has no precommit function. ' 'The function is required to run the ' 'plugin as git pre commit hook.'.format( @@ -121,19 +161,19 @@ def check_config(pyproject_toml: Path, hook_mode: Mode) -> None: ) ) elif not has_precommit_parameters(plugin): - warning( + term.warning( 'Plugin "{}" uses a deprecated signature ' 'for its precommit function. It is missing ' 'the **kwargs parameter.'.format(name) ) else: - ok( + term.ok( 'Plugin "{}" active and loadable.'.format( name ) ) except ImportError as e: - error( + term.error( '"{}" is not a valid autohooks ' 'plugin. {}'.format(name, e) ) diff --git a/autohooks/precommit/run.py b/autohooks/precommit/run.py index bb0e2b47..bfa1796f 100644 --- a/autohooks/precommit/run.py +++ b/autohooks/precommit/run.py @@ -24,10 +24,11 @@ from contextlib import contextmanager +from autohooks.api import _set_terminal from autohooks.config import load_config_from_pyproject_toml from autohooks.hooks import PreCommitHook from autohooks.settings import Mode -from autohooks.terminal import error, warning +from autohooks.terminal import Terminal from autohooks.utils import get_project_autohooks_plugins_path @@ -58,35 +59,40 @@ def has_precommit_parameters(plugin: ModuleType) -> bool: return bool(signature.parameters) -def check_hook_is_current(pre_commit_hook: PreCommitHook): +def check_hook_is_current( + term: Terminal, pre_commit_hook: PreCommitHook +) -> None: if not pre_commit_hook.is_current_autohooks_pre_commit_hook(): - warning( + term.warning( 'autohooks pre-commit hook is outdated. Please run ' '\'autohooks activate --force\' to update your pre-commit ' 'hook.' ) -def check_hook_mode(config_mode: Mode, hook_mode: Mode) -> None: - if config_mode != hook_mode: - warning( - 'autohooks mode in pre-commit hook ("{}") differs from ' - 'mode in pyproject.toml file ("{}"). Please run \'autohooks ' - 'activate --force\' to enforce {} mode.'.format( - str(hook_mode), str(config_mode), str(config_mode) +def check_hook_mode(term: Terminal, config_mode: Mode, hook_mode: Mode) -> None: + if config_mode.get_effective_mode() != hook_mode.get_effective_mode(): + term.warning( + 'autohooks mode "{}" in pre-commit hook differs from ' + 'mode "{}" in pyproject.toml file.'.format( + str(hook_mode), str(config_mode) ) ) def run() -> int: - print('autohooks => pre-commit') + term = Terminal() + + _set_terminal(term) config = load_config_from_pyproject_toml() pre_commit_hook = PreCommitHook() - check_hook_is_current(pre_commit_hook) - check_hook_mode(config.get_mode(), pre_commit_hook.read_mode()) + check_hook_is_current(term, pre_commit_hook) + + if config.has_autohooks_config(): + check_hook_mode(term, config.get_mode(), pre_commit_hook.read_mode()) plugins = get_project_autohooks_plugins_path() plugins_dir_name = str(plugins) @@ -94,40 +100,47 @@ def run() -> int: if plugins.is_dir(): sys.path.append(plugins_dir_name) - with autohooks_module_path(): + term.print('autohooks => pre-commit') + + with autohooks_module_path(), term.indent(): for name in config.get_pre_commit_script_names(): - try: - plugin = load_plugin(name) - if not has_precommit_function(plugin): - error( - 'No precommit function found in plugin {}. ' - 'Your autohooks settings may be invalid.'.format(name) + term.print('Running {}'.format(name)) + + with term.indent(): + try: + plugin = load_plugin(name) + if not has_precommit_function(plugin): + term.error( + 'No precommit function found in plugin {}. ' + 'Your autohooks settings may be invalid.'.format( + name + ) + ) + return 1 + + if has_precommit_parameters(plugin): + retval = plugin.precommit(config=config.get_config()) + else: + term.warning( + 'precommit function without kwargs is deprecated. ' + 'Please update {} to a newer version.'.format(name) + ) + retval = plugin.precommit() + + if retval: + return retval + + except ImportError as e: + term.error( + 'An error occurred while importing pre-commit ' + 'hook {}. {}.'.format(name, e) ) return 1 - - if has_precommit_parameters(plugin): - retval = plugin.precommit(config=config.get_config()) - else: - warning( - 'precommit function without kwargs is deprecated. ' - 'Please update {} to a newer version.'.format(name) + except Exception as e: # pylint: disable=broad-except + term.error( + 'An error occurred while running pre-commit ' + 'hook {}. {}.'.format(name, e) ) - retval = plugin.precommit() - - if retval: - return retval - - except ImportError as e: - error( - 'An error occurred while importing pre-commit ' - 'hook {}. {}.'.format(name, e) - ) - return 1 - except Exception as e: # pylint: disable=broad-except - error( - 'An error occurred while running pre-commit ' - 'hook {}. {}.'.format(name, e) - ) - return 1 + return 1 return 0 diff --git a/autohooks/terminal.py b/autohooks/terminal.py index ab20de61..f014ef34 100644 --- a/autohooks/terminal.py +++ b/autohooks/terminal.py @@ -16,31 +16,62 @@ # along with this program. If not, see . import curses -from blessings import Terminal +from contextlib import contextmanager -try: - term = Terminal() # pylint: disable=invalid-name -except curses.error: - # handle issues with terminals and force not to style anything - # should not be necessary with blessings > 1.7 anymore - term = Terminal(force_styling=None) # pylint: disable=invalid-name +from typing import Callable, Generator +from blessings import Terminal as Term -def ok(message: str) -> None: - print(message, '[', term.green('ok'), ']') +class Terminal: + def __init__(self): + self._indent = 0 + try: + self._term = Term() + except curses.error: + # handle issues with terminals and force not to style anything + # should not be necessary with blessings > 1.7 anymore + self._term = Term(force_styling=None) -def fail(message: str) -> None: - print(message, '[', term.red('fail'), ']') + def _print_end(self, message: str, status: str, color: Callable) -> None: + width = self._term.width - 1 + extra = 4 # '[ ', ' ]' + with self._term.location(): + self.print( + message, + self._term.move_x(width - extra - len(status)), + '[', + color(status), + ']', + ) + @contextmanager + def indent(self, indentation: int = 4) -> Generator: + current_indent = self._indent + self.add_indent(indentation) -def error(message: str) -> None: - print(message, '[', term.red('error'), ']') + yield self + self._indent = current_indent -def warning(message: str) -> None: - print(message, '[', term.yellow('warning'), ']') + def add_indent(self, indentation: int = 4) -> None: + self._indent += indentation + def print(self, *messages: str) -> None: + with self._term.location(x=self._indent): + print(*messages) -def info(message: str) -> None: - print(message, '[', term.cyan('info'), ']') + def ok(self, message: str) -> None: + self._print_end(message, 'ok', self._term.green) + + def fail(self, message: str) -> None: + self._print_end(message, 'fail', self._term.red) + + def error(self, message: str) -> None: + self._print_end(message, 'error', self._term.red) + + def warning(self, message: str) -> None: + self._print_end(message, 'warning', self._term.yellow) + + def info(self, message: str) -> None: + self._print_end(message, 'info', self._term.cyan) diff --git a/tests/api/test_path.py b/tests/api/test_path.py index 1efceb33..85289941 100644 --- a/tests/api/test_path.py +++ b/tests/api/test_path.py @@ -58,3 +58,7 @@ def test_match_files_in_subdir(self): self.assertFalse(match(Path('foo/bar.js'), patterns)) self.assertFalse(match(Path('bar/foo.py'), patterns)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/api/test_terminal.py b/tests/api/test_terminal.py new file mode 100644 index 00000000..ed0084e3 --- /dev/null +++ b/tests/api/test_terminal.py @@ -0,0 +1,57 @@ +# Copyright (C) 2019 Greenbone Networks GmbH +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import unittest + +from unittest.mock import Mock + +from autohooks.api import _set_terminal, error, fail, info, ok, out, warning +from autohooks.terminal import Terminal + + +class TerminalOutputApiTestCase(unittest.TestCase): + def setUp(self): + self.term = Mock(spec=Terminal) + _set_terminal(self.term) + + def test_error(self): + error('foo bar') + self.term.error.assert_called_with('foo bar') + + def test_fail(self): + fail('foo bar') + self.term.fail.assert_called_with('foo bar') + + def test_info(self): + info('foo bar') + self.term.info.assert_called_with('foo bar') + + def test_ok(self): + ok('foo bar') + self.term.ok.assert_called_with('foo bar') + + def test_out(self): + out('foo bar') + self.term.print.assert_called_with('foo bar') + + def test_warning(self): + warning('foo bar') + self.term.warning.assert_called_with('foo bar') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_terminal.py b/tests/test_terminal.py new file mode 100644 index 00000000..85cdf380 --- /dev/null +++ b/tests/test_terminal.py @@ -0,0 +1,206 @@ +# Copyright (C) 2019 Greenbone Networks GmbH +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# pylint: disable=invalid-name, protected-access + +import unittest + +import sys + +from unittest.mock import Mock, MagicMock, patch + +from autohooks.terminal import Terminal + + +class PropertyMagicMock(MagicMock): + def __get__(self, obj, obj_type=None): + return self() + + def __set__(self, obj, val): + self(val) + + def _get_child_mock(self, **kwargs): + return MagicMock(**kwargs) + + +class TerminalTestCase(unittest.TestCase): + def assertCalled(self, mock): + if sys.version_info[1] < 6: + self.assertEqual( + mock.call_count, + 1, + '{} not called'.format(mock._mock_name or 'mock'), + ) + else: + mock.assert_called() + + def setUp(self): + self.print_patcher = patch('builtins.print') + self.terminal_patcher = patch('autohooks.terminal.Term', spec=True) + + terminal_mock_class = self.terminal_patcher.start() + self.print_mock = self.print_patcher.start() + + self.width_mock = PropertyMagicMock(return_value=80, spec=1) + + self.terminal_mock = terminal_mock_class.return_value + + # "Because of the way mock attributes are stored you can’t directly + # attach a PropertyMock to a mock object. Instead you can attach it to + # the mock type object" + # https://docs.python.org/3/library/unittest.mock.html#unittest.mock.PropertyMock + type(self.terminal_mock).width = self.width_mock + + self.terminal_mock.cyan = Mock() + self.terminal_mock.green = Mock() + self.terminal_mock.red = Mock() + self.terminal_mock.yellow = Mock() + + self.terminal_mock.move_x = Mock() + + def tearDown(self): + self.print_patcher.stop() + self.terminal_patcher.stop() + + def test_error(self): + term = Terminal() + term.error('foo bar') + + # width has been calculated + self.width_mock.assert_called_with() + + # 70 == 80 - 5 - len('error') + self.terminal_mock.move_x.assert_called_with(70) + + # error has been printed in red + self.terminal_mock.red.assert_called_with('error') + + # an actual output has been generated + self.assertCalled(self.print_mock) + + def test_fail(self): + term = Terminal() + term.fail('foo bar') + + # width has been calculated + self.width_mock.assert_called_with() + + # 71 == 80 - 5 - len('fail') + self.terminal_mock.move_x.assert_called_with(71) + + # fail has been printed in red + self.terminal_mock.red.assert_called_with('fail') + + # an actual output has been generated + self.assertCalled(self.print_mock) + + def test_info(self): + term = Terminal() + term.info('foo bar') + + # width has been calculated + self.width_mock.assert_called_with() + + # 71 == 80 - 5 - len('info') + self.terminal_mock.move_x.assert_called_with(71) + + # info has been printed in cyan + self.terminal_mock.cyan.assert_called_with('info') + + # an actual output has been generated + self.assertCalled(self.print_mock) + + def test_ok(self): + term = Terminal() + term.ok('foo bar') + + # width has been calculated + self.width_mock.assert_called_with() + + # 73 == 80 - 5 - len('ok') + self.terminal_mock.move_x.assert_called_with(73) + + # ok has been printed in green + self.terminal_mock.green.assert_called_with('ok') + + # an actual output has been generated + self.assertCalled(self.print_mock) + + def test_warning(self): + term = Terminal() + term.warning('foo bar') + + # width has been calculated + self.width_mock.assert_called_with() + + # 68 == 80 - 5 - len('warning') + self.terminal_mock.move_x.assert_called_with(68) + + # warning has been printed in yellow + self.terminal_mock.yellow.assert_called_with('warning') + + # an actual output has been generated + self.assertCalled(self.print_mock) + + def test_print(self): + term = Terminal() + term.print('foo bar') + + # printed output at current indent location + self.terminal_mock.location.assert_called_with(x=0) + + # an actual output has been generated + self.print_mock.assert_called_with('foo bar') + + def test_add_indent(self): + term = Terminal() + term.add_indent(6) + term.print('foo') + + # printed output at current indent location + self.terminal_mock.location.assert_called_with(x=6) + + # an actual output has been generated + self.print_mock.assert_called_with('foo') + + term.add_indent(4) + term.print('bar') + + # printed output at current indent location + self.terminal_mock.location.assert_called_with(x=10) + + # an actual output has been generated + self.print_mock.assert_called_with('bar') + + def test_with_indent(self): + term = Terminal() + + with term.indent(2): + term.print('foo') + + self.terminal_mock.location.assert_called_with(x=2) + self.print_mock.assert_called_with('foo') + + term.print('bar') + + # indentation has been removed + self.terminal_mock.location.assert_called_with(x=0) + self.print_mock.assert_called_with('bar') + + +if __name__ == '__main__': + unittest.main()