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)