diff --git a/autohooks/cli/__init__.py b/autohooks/cli/__init__.py index cbeae4d2..0f9a6d13 100644 --- a/autohooks/cli/__init__.py +++ b/autohooks/cli/__init__.py @@ -21,6 +21,12 @@ from autohooks.__version__ import __version__ as version from autohooks.cli.activate import install_hooks from autohooks.cli.check import check_hooks +from autohooks.cli.plugins import ( + add_plugins, + list_plugins, + plugins, + remove_plugins, +) from autohooks.settings import Mode from autohooks.terminal import Terminal @@ -64,6 +70,34 @@ def main(): ) check_parser.set_defaults(func=check_hooks) + plugins_parser = subparsers.add_parser( + "plugins", help="Manage autohooks plugins" + ) + plugins_parser.set_defaults(func=plugins) + + plugins_subparsers = plugins_parser.add_subparsers( + dest="subcommand", required=True + ) + + add_plugins_parser = plugins_subparsers.add_parser( + "add", help="Add plugins." + ) + add_plugins_parser.set_defaults(plugins_func=add_plugins) + add_plugins_parser.add_argument("name", nargs="+", help="Plugin(s) to add") + + remove_plugins_parser = plugins_subparsers.add_parser( + "remove", help="Remove plugins." + ) + remove_plugins_parser.set_defaults(plugins_func=remove_plugins) + remove_plugins_parser.add_argument( + "name", nargs="+", help="Plugin(s) to remove" + ) + + list_plugins_parser = plugins_subparsers.add_parser( + "list", help="List current used plugins." + ) + list_plugins_parser.set_defaults(plugins_func=list_plugins) + args = parser.parse_args() if not args.command: diff --git a/autohooks/cli/plugins.py b/autohooks/cli/plugins.py new file mode 100644 index 00000000..c624c09f --- /dev/null +++ b/autohooks/cli/plugins.py @@ -0,0 +1,136 @@ +# Copyright (C) 2022 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 . + +from argparse import Namespace +from typing import Iterable + +from autohooks.config import load_config_from_pyproject_toml +from autohooks.precommit.run import autohooks_module_path, check_plugin +from autohooks.settings import AutohooksSettings +from autohooks.terminal import Terminal +from autohooks.utils import get_pyproject_toml_path + + +def plugins(term: Terminal, args: Namespace) -> None: + args.plugins_func(term, args) + + +def print_current_plugins( + term: Terminal, current_plugins: Iterable[str] +) -> None: + """ + Print the currently used plugins to the terminal + """ + term.info("Currently used plugins:") + with term.indent(), autohooks_module_path(): + if not current_plugins: + term.print("None") + return + + for plugin in sorted(current_plugins): + result = check_plugin(plugin) + if result: + term.error(f'"{plugin}": {result}') + else: + term.ok(f'"{plugin}"') + + +# pylint: disable=unused-argument +def list_plugins(term: Terminal, args: Namespace) -> None: + """ + CLI handler function to list the currently used plugins + """ + pyproject_toml = get_pyproject_toml_path() + config = load_config_from_pyproject_toml(pyproject_toml) + + current_plugins = ( + config.settings.pre_commit if config.has_autohooks_config() else [] + ) + print_current_plugins(term, current_plugins) + + +def add_plugins(term: Terminal, args: Namespace) -> None: + """ + CLI handler function to add new plugins + """ + pyproject_toml = get_pyproject_toml_path() + config = load_config_from_pyproject_toml(pyproject_toml) + plugins_to_add = set(args.name) + + if config.has_autohooks_config(): + settings = config.settings + existing_plugins = set(settings.pre_commit) + all_plugins = plugins_to_add | existing_plugins + duplicate_plugins = plugins_to_add & existing_plugins + new_plugins = plugins_to_add - existing_plugins + settings.pre_commit = all_plugins + + if duplicate_plugins: + term.info("Skipped already used plugins:") + with term.indent(): + for plugin in sorted(duplicate_plugins): + term.warning(f'"{plugin}"') + else: + all_plugins = plugins_to_add + new_plugins = plugins_to_add + settings = AutohooksSettings(pre_commit=all_plugins) + config.settings = settings + + settings.write(pyproject_toml) + + if new_plugins: + term.info("Added plugins:") + with term.indent(): + for plugin in sorted(new_plugins): + term.ok(f'"{plugin}"') + + print_current_plugins(term, all_plugins) + + +def remove_plugins(term: Terminal, args: Namespace) -> None: + """ + CLI handler function to remove plugins + """ + pyproject_toml = get_pyproject_toml_path() + config = load_config_from_pyproject_toml(pyproject_toml) + plugins_to_remove = set(args.name) + + if config.has_autohooks_config(): + settings = config.settings + existing_plugins = set(settings.pre_commit) + removed_plugins = existing_plugins & plugins_to_remove + all_plugins = existing_plugins - plugins_to_remove + skipped_plugins = plugins_to_remove - existing_plugins + settings.pre_commit = all_plugins + + if skipped_plugins: + term.info("Skipped not used plugins:") + with term.indent(): + for plugin in sorted(skipped_plugins): + term.warning(f'"{plugin}"') + + if removed_plugins: + term.info("Removed plugins:") + with term.indent(): + for plugin in sorted(removed_plugins): + term.ok(f'"{plugin}"') + + settings.write(pyproject_toml) + + print_current_plugins(term, all_plugins) + else: + term.warning("No plugins to remove.") diff --git a/tests/cli/test_plugins.py b/tests/cli/test_plugins.py new file mode 100644 index 00000000..bd505c8d --- /dev/null +++ b/tests/cli/test_plugins.py @@ -0,0 +1,240 @@ +# Copyright (C) 2022 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 argparse import Namespace +from unittest.mock import MagicMock, call + +from autohooks.cli.plugins import add_plugins, list_plugins, remove_plugins +from tests import temp_file, tempdir, unload_module + + +class AddPluginsCliTestCase(unittest.TestCase): + def test_add_plugin(self): + term = MagicMock() + args = Namespace(name=["foo", "bar"]) + + expected = """[tool.autohooks] +mode = "pythonpath" +pre-commit = ["bar", "foo"] +""" + + with tempdir(change_into=True) as tmp_dir: + add_plugins(term, args) + + term.warning.assert_not_called() + term.info.assert_has_calls([call("Added plugins:")]) + term.ok.assert_has_calls([call('"bar"'), call('"foo"')]) + + pyproject_toml = tmp_dir / "pyproject.toml" + content = pyproject_toml.read_text(encoding="utf8") + + self.assertEqual(content, expected) + + def test_add_plugin_with_existing(self): + term = MagicMock() + args = Namespace(name=["foo"]) + existing = """[tool.autohooks] +mode = "poetry" +pre-commit = ["bar"] +""" + + expected = """[tool.autohooks] +mode = "poetry" +pre-commit = ["bar", "foo"] +""" + + with temp_file( + existing, name="pyproject.toml", change_into=True + ) as pyproject_toml: + add_plugins(term, args) + + term.warning.assert_not_called() + term.info.assert_has_calls( + [call("Added plugins:"), call("Currently used plugins:")] + ) + term.ok.assert_has_calls([call('"foo"')]) + + content = pyproject_toml.read_text(encoding="utf8") + + self.assertEqual(content, expected) + + def test_add_duplicate_plugin(self): + term = MagicMock() + args = Namespace(name=["foo", "bar"]) + existing = """[tool.autohooks] +mode = "poetry" +pre-commit = ["bar"] +""" + + expected = """[tool.autohooks] +mode = "poetry" +pre-commit = ["bar", "foo"] +""" + + with temp_file( + existing, name="pyproject.toml", change_into=True + ) as pyproject_toml: + add_plugins(term, args) + + term.warning.assert_has_calls([call('"bar"')]) + term.info.assert_has_calls( + [ + call("Skipped already used plugins:"), + call("Added plugins:"), + call("Currently used plugins:"), + ] + ) + term.ok.assert_has_calls([call('"foo"')]) + + content = pyproject_toml.read_text(encoding="utf8") + + self.assertEqual(content, expected) + + +class ListPluginsCliTestCase(unittest.TestCase): + def setUp(self) -> None: + unload_module("bar") + return super().setUp() + + def test_invalid_plugins(self): + term = MagicMock() + args = Namespace() + + existing = """[tool.autohooks] +mode = "poetry" +pre-commit = ["bar"] +""" + with temp_file(existing, name="pyproject.toml", change_into=True): + list_plugins(term, args) + + term.warning.assert_not_called() + term.info.assert_has_calls( + [ + call("Currently used plugins:"), + ] + ) + term.ok.assert_not_called() + term.error.assert_called_once_with( + '"bar": "bar" is not a valid autohooks plugin. No module ' + "named 'bar'" + ) + + def test_existing_plugins(self): + term = MagicMock() + args = Namespace() + + pyproject_toml_content = """[tool.autohooks] +mode = "poetry" +pre-commit = ["bar"] +""" + + plugin_content = """ +def precommit(config=None, **kwargs): + pass +""" + with tempdir(change_into=True, add_to_sys_path=True) as tmp_dir: + pyproject_toml = tmp_dir / "pyproject.toml" + pyproject_toml.write_text(pyproject_toml_content, encoding="utf8") + + bar_plugin = tmp_dir / "bar.py" + bar_plugin.write_text(plugin_content, encoding="utf8") + + list_plugins(term, args) + + term.warning.assert_not_called() + term.info.assert_has_calls( + [ + call("Currently used plugins:"), + ] + ) + term.error.assert_not_called() + term.ok.assert_called_once_with('"bar"') + + +class RemovePluginsCliTestCase(unittest.TestCase): + def test_remove_plugins(self): + term = MagicMock() + args = Namespace(name=["foo", "bar"]) + + existing = """[tool.autohooks] +mode = "pythonpath" +pre-commit = ["bar", "foo"] +""" + expected = """[tool.autohooks] +mode = "pythonpath" +pre-commit = [] +""" + + with temp_file( + existing, name="pyproject.toml", change_into=True + ) as pyproject_toml: + remove_plugins(term, args) + + term.warning.assert_not_called() + term.info.assert_has_calls([call("Removed plugins:")]) + term.ok.assert_has_calls([call('"bar"'), call('"foo"')]) + + content = pyproject_toml.read_text(encoding="utf8") + + self.assertEqual(content, expected) + + def test_no_config(self): + term = MagicMock() + args = Namespace(name=["foo", "bar"]) + + with tempdir(change_into=True) as temp_dir: + remove_plugins(term, args) + + term.warning.assert_called_once_with("No plugins to remove.") + term.info.assert_not_called() + term.ok.assert_not_called() + + pyproject_toml = temp_dir / "pyproject.toml" + self.assertFalse(pyproject_toml.exists()) + + def test_remove_plugins_skipped(self): + term = MagicMock() + args = Namespace(name=["foo", "bar"]) + + existing = """[tool.autohooks] +mode = "pythonpath" +pre-commit = ["foo"] +""" + expected = """[tool.autohooks] +mode = "pythonpath" +pre-commit = [] +""" + + with temp_file( + existing, name="pyproject.toml", change_into=True + ) as pyproject_toml: + remove_plugins(term, args) + + term.warning.assert_called_once_with('"bar"') + term.info.assert_has_calls( + [ + call("Skipped not used plugins:"), + call("Removed plugins:"), + call("Currently used plugins:"), + ] + ) + term.ok.assert_has_calls([call('"foo"')]) + + content = pyproject_toml.read_text(encoding="utf8") + + self.assertEqual(content, expected)