From c2d49292918c98bd228fcf4bea02ca142de5c1a3 Mon Sep 17 00:00:00 2001 From: Mike Hennessy Date: Sat, 8 Jan 2022 12:57:32 -0500 Subject: [PATCH] feat: add the ability to load plugins in config --- CONTRIBUTING.md | 4 +-- README.md | 23 ++++++++++++--- src/dotbot/cli.py | 4 +-- src/dotbot/dispatcher.py | 19 ++++++++++-- src/dotbot/plugins/__init__.py | 3 +- src/dotbot/plugins/plugins.py | 47 ++++++++++++++++++++++++++++++ tests/dotbot_plugin_config_file.py | 25 ++++++++++++++++ 7 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 src/dotbot/plugins/plugins.py create mode 100644 tests/dotbot_plugin_config_file.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 699e175..fa9855b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ feature request][issue]. It's great to hear about new ideas. If you are inclined to do so, you're welcome to [fork][fork] Dotbot, work on implementing the feature yourself, and submit a patch. In this case, it's -*highly recommended* that you first [open an issue][issue] describing your +_highly recommended_ that you first [open an issue][issue] describing your enhancement to get early feedback on the new feature that you are implementing. This will help avoid wasted efforts and ensure that your work is incorporated into the code base. @@ -35,7 +35,7 @@ Want to hack on Dotbot? Awesome! If there are [open issues][issues], you're more than welcome to work on those - this is probably the best way to contribute to Dotbot. If you have your own ideas, that's great too! In that case, before working on substantial changes to -the code base, it is *highly recommended* that you first [open an issue][issue] +the code base, it is _highly recommended_ that you first [open an issue][issue] describing what you intend to work on. **Patches are generally submitted as pull requests.** Patches are also diff --git a/README.md b/README.md index f8108a6..c21c43b 100644 --- a/README.md +++ b/README.md @@ -429,12 +429,27 @@ should do something and return whether or not it completed successfully. All built-in Dotbot directives are written as plugins that are loaded by default, so those can be used as a reference when writing custom plugins. -Plugins are loaded using the `--plugin` and `--plugin-dir` options, using -either absolute paths or paths relative to the base directory. It is -recommended that these options are added directly to the `install` script. - See [here][plugins] for a current list of plugins. +#### Format + +Plugins can be loaded either by the command-line arguments `--plugin` or +`--plugin-dir` or by the `plugins` directive. Each of these take either +absolute paths or paths relative to the base directory. + +When using command-line arguments to load multiple plugins you must add +one argument for each plugin to be loaded. It is recommended to place +these command-line arguments directly in the `install` script. + +The `plugins` config directive is specified as an array of paths to load. + +#### Example + +```yaml +- plugins: + - dotbot-plugins/dotbot-template +``` + ## Command-line Arguments Dotbot takes a number of command-line arguments; you can run Dotbot with diff --git a/src/dotbot/cli.py b/src/dotbot/cli.py index a4607db..bd76950 100644 --- a/src/dotbot/cli.py +++ b/src/dotbot/cli.py @@ -9,7 +9,7 @@ import dotbot from dotbot.config import ConfigReader, ReadingError from dotbot.dispatcher import Dispatcher, DispatchError, _all_plugins from dotbot.messenger import Level, Messenger -from dotbot.plugins import Clean, Create, Link, Shell +from dotbot.plugins import Clean, Create, Link, Plugins, Shell from dotbot.util import module @@ -101,7 +101,7 @@ def main() -> None: plugins = [] plugin_directories = list(options.plugin_dirs) if not options.disable_built_in_plugins: - plugins.extend([Clean, Create, Link, Shell]) + plugins.extend([Clean, Create, Link, Plugins, Shell]) plugin_paths = [] for directory in plugin_directories: plugin_paths.extend(glob.glob(os.path.join(directory, "*.py"))) diff --git a/src/dotbot/dispatcher.py b/src/dotbot/dispatcher.py index 6baec1e..5d12b7c 100644 --- a/src/dotbot/dispatcher.py +++ b/src/dotbot/dispatcher.py @@ -37,7 +37,10 @@ class Dispatcher: self._exit = exit_on_failure def _setup_context( - self, base_directory: str, options: Optional[Namespace], plugins: Optional[List[Type[Plugin]]] + self, + base_directory: str, + options: Optional[Namespace], + plugins: Optional[List[Type[Plugin]]], ) -> None: path = os.path.abspath(os.path.expanduser(base_directory)) if not os.path.exists(path): @@ -62,6 +65,7 @@ class Dispatcher: self._context.set_defaults(task[action]) # replace, not update handled = True # keep going, let other plugins handle this if they want + for plugin in self._plugins: if plugin.can_handle(action): try: @@ -76,14 +80,25 @@ class Dispatcher: self._log.error(f"An error was encountered while executing action {action}") self._log.debug(str(err)) if self._exit: - # There was an execption exit + # There was an exception exit return False + + if action == "plugins": + # Create a list of loaded plugin names + loaded_plugins = [type(plugin).__name__ for plugin in self._plugins] + + # Load plugins that haven't been loaded yet + for plugin_subclass in Plugin.__subclasses__(): + if type(plugin_subclass).__name__ not in loaded_plugins: + self._plugins.append(plugin_subclass(self._context)) + if not handled: success = False self._log.error(f"Action {action} not handled") if self._exit: # Invalid action exit return False + return success diff --git a/src/dotbot/plugins/__init__.py b/src/dotbot/plugins/__init__.py index d2dfa0b..4637bf4 100644 --- a/src/dotbot/plugins/__init__.py +++ b/src/dotbot/plugins/__init__.py @@ -1,6 +1,7 @@ from dotbot.plugins.clean import Clean from dotbot.plugins.create import Create from dotbot.plugins.link import Link +from dotbot.plugins.plugins import Plugins from dotbot.plugins.shell import Shell -__all__ = ["Clean", "Create", "Link", "Shell"] +__all__ = ["Clean", "Create", "Link", "Plugins", "Shell"] diff --git a/src/dotbot/plugins/plugins.py b/src/dotbot/plugins/plugins.py new file mode 100644 index 0000000..544d6a3 --- /dev/null +++ b/src/dotbot/plugins/plugins.py @@ -0,0 +1,47 @@ +import glob +import os +from typing import Any + +from dotbot.plugin import Plugin +from dotbot.util import module + + +class Plugins(Plugin): + """ + Load plugins from a list of paths. + """ + + _directive = "plugins" + _has_shown_override_message = False + + def can_handle(self, directive: str) -> bool: + return directive == self._directive + + def handle(self, directive: str, data: Any) -> bool: + if directive != self._directive: + msg = f"plugins cannot handle directive {directive}" + raise ValueError(msg) + return self._process_plugins(data) + + def _process_plugins(self, data: Any) -> bool: + success = True + plugin_paths = [] + for item in data: + self._log.lowinfo(f"Loading plugin from {item}") + + plugin_path_globs = glob.glob(os.path.join(item, "*.py")) + if not plugin_path_globs: + success = False + self._log.warning(f"Failed to load plugin from {item}") + else: + plugin_paths = list(plugin_path_globs) + + for plugin_path in plugin_paths: + abspath = os.path.abspath(plugin_path) + module.load(abspath) + + if success: + self._log.info("All commands have been executed") + else: + self._log.error("Some commands were not successfully executed") + return success diff --git a/tests/dotbot_plugin_config_file.py b/tests/dotbot_plugin_config_file.py new file mode 100644 index 0000000..76541b8 --- /dev/null +++ b/tests/dotbot_plugin_config_file.py @@ -0,0 +1,25 @@ +"""Test that a plugin can be loaded by config file. + +This file is copied to a location with the name "config_file.py", +and is then loaded from within the `test_cli.py` code. +""" + +import os.path +from typing import Any + +import dotbot + + +class ConfigFile(dotbot.Plugin): + _directive = "plugin_config_file" + + def can_handle(self, directive: str) -> bool: + return directive == self._directive + + def handle(self, directive: str, _data: Any) -> bool: + if not self.can_handle(directive): + msg = f"ConfigFile cannot handle directive {directive}" + raise ValueError(msg) + with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file: + file.write("config file plugin loading works") + return True