From 7c7b1a429520ebff00dfdc953043d69f9775c410 Mon Sep 17 00:00:00 2001 From: Gregory Furlong Date: Sat, 1 Oct 2022 16:30:44 -0400 Subject: [PATCH] Add generic conditional container plugin. --- dotbot/cli.py | 3 ++- dotbot/condition.py | 27 ++++++++++++++++++++++ dotbot/conditions/__init__.py | 2 ++ dotbot/conditions/shell.py | 20 ++++++++++++++++ dotbot/conditions/tty.py | 20 ++++++++++++++++ dotbot/context.py | 3 ++- dotbot/plugins/__init__.py | 1 + dotbot/plugins/conditional.py | 43 +++++++++++++++++++++++++++++++++++ dotbot/tester.py | 40 ++++++++++++++++++++++++++++++++ sample.conf.yaml | 26 +++++++++++++++++++++ 10 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 dotbot/condition.py create mode 100644 dotbot/conditions/__init__.py create mode 100644 dotbot/conditions/shell.py create mode 100644 dotbot/conditions/tty.py create mode 100644 dotbot/plugins/conditional.py create mode 100644 dotbot/tester.py create mode 100644 sample.conf.yaml diff --git a/dotbot/cli.py b/dotbot/cli.py index 28485d1..c68850c 100644 --- a/dotbot/cli.py +++ b/dotbot/cli.py @@ -120,7 +120,8 @@ def main(): plugin_directories = list(options.plugin_dirs) if not options.disable_built_in_plugins: - from .plugins import Clean, Create, Link, Shell + from .plugins import Clean, Create, Link, Shell, Conditional + from .conditions import ShellCondition, TtyCondition plugin_paths = [] for directory in plugin_directories: for plugin_path in glob.glob(os.path.join(directory, "*.py")): diff --git a/dotbot/condition.py b/dotbot/condition.py new file mode 100644 index 0000000..17fcdfc --- /dev/null +++ b/dotbot/condition.py @@ -0,0 +1,27 @@ +import dotbot +import dotbot.util + +from .messenger import Messenger + +class Condition(object): + + """ + Abstract base class for conditions that test whether dotbot should execute tasks/actions + """ + + def __init__(self, context): + self._context = context + self._log = Messenger() + + def can_handle(self, directive): + """ + Returns true if the Condition can handle the directive. + """ + raise NotImplementedError + + def handle(self, directive, data): + """ + Executes the test. + Returns the boolean value returned by the test + """ + raise NotImplementedError diff --git a/dotbot/conditions/__init__.py b/dotbot/conditions/__init__.py new file mode 100644 index 0000000..edeba1d --- /dev/null +++ b/dotbot/conditions/__init__.py @@ -0,0 +1,2 @@ +from .shell import ShellCondition +from .tty import TtyCondition diff --git a/dotbot/conditions/shell.py b/dotbot/conditions/shell.py new file mode 100644 index 0000000..85dedf0 --- /dev/null +++ b/dotbot/conditions/shell.py @@ -0,0 +1,20 @@ +from ..condition import Condition +import dotbot.util + +class ShellCondition(Condition): + + """ + Condition testing an arbitrary shell command and evaluating to true if the return code is zero + """ + + _directive = "shell" + + def can_handle(self, directive): + return directive == self._directive + + def handle(self, directive, command): + if directive != self._directive: + raise ValueError("ShellCondition cannot handle directive %s" % directive) + + ret = dotbot.util.shell_command(command, cwd=self._context.base_directory()) + return ret == 0 diff --git a/dotbot/conditions/tty.py b/dotbot/conditions/tty.py new file mode 100644 index 0000000..ba3539c --- /dev/null +++ b/dotbot/conditions/tty.py @@ -0,0 +1,20 @@ +from ..condition import Condition + +import sys + +class TtyCondition(Condition): + + """ + Condition testing if stdin is a TTY (allowing to request input from the user) + """ + + _directive = "tty" + + def can_handle(self, directive): + return directive == self._directive + + def handle(self, directive, data=True): + if directive != self._directive: + raise ValueError("Tty cannot handle directive %s" % directive) + expected = data if data is not None else True + return expected == (sys.stdin.isatty() and sys.stdout.isatty() and sys.stderr.isatty()) diff --git a/dotbot/context.py b/dotbot/context.py index da10ba4..ab140e8 100644 --- a/dotbot/context.py +++ b/dotbot/context.py @@ -8,10 +8,11 @@ class Context(object): Contextual data and information for plugins. """ - def __init__(self, base_directory, options=Namespace()): + def __init__(self, base_directory, options=Namespace(), dispatcher=None): self._base_directory = base_directory self._defaults = {} self._options = options + self._dispatcher = dispatcher pass def set_base_directory(self, base_directory): diff --git a/dotbot/plugins/__init__.py b/dotbot/plugins/__init__.py index f75bef5..98e5916 100644 --- a/dotbot/plugins/__init__.py +++ b/dotbot/plugins/__init__.py @@ -1,4 +1,5 @@ from .clean import Clean +from .conditional import Conditional from .create import Create from .link import Link from .shell import Shell diff --git a/dotbot/plugins/conditional.py b/dotbot/plugins/conditional.py new file mode 100644 index 0000000..a6ddd01 --- /dev/null +++ b/dotbot/plugins/conditional.py @@ -0,0 +1,43 @@ +import dotbot +from dotbot.dispatcher import Dispatcher +from dotbot.tester import Tester + +class Conditional(dotbot.Plugin): + + ''' + Conditionally execute nested commands based on the result of configured test(s) + ''' + + _directive = "conditional" + + def can_handle(self, directive): + return directive == self._directive + + def handle(self, directive, data): + if directive != self._directive: + raise ValueError("Conditional cannot handle directive %s" % directive) + return self._process_conditional(data) + + def _process_conditional(self, data): + success = True + tests = data.get("if") + test_result = Tester(self._context).evaluate(tests) + + tasks = data.get("then") if test_result else data.get("else") + self._log.info("executing sub-commands") + # TODO prepend/extract defaults if scope_defaults is False + if tasks is not None: + return self._execute_tasks(tasks) + else: + return True + + def _execute_tasks(self, data): + # TODO improve handling of defaults either by reusing context/dispatcher -OR- prepend defaults & extract at end + dispatcher = Dispatcher(self._context.base_directory(), + only=self._context.options().only, + skip=self._context.options().skip, + options=self._context.options()) + # if the data is a dictionary, wrap it in a list + data = data if type(data) is list else [ data ] + return dispatcher.dispatch(data) +# return self._context._dispatcher.dispatch(data) diff --git a/dotbot/tester.py b/dotbot/tester.py new file mode 100644 index 0000000..3d9928f --- /dev/null +++ b/dotbot/tester.py @@ -0,0 +1,40 @@ +from dotbot.condition import Condition +from dotbot.messenger import Messenger + +class Tester(object): + + def __init__(self, context): + self._context = context + self._log = Messenger() + self.__load_conditions() + + def __load_conditions(self): + self._plugins = [plugin(self._context) for plugin in Condition.__subclasses__()] + + def evaluate(self, tests): + normalized = self.normalize_tests(tests) + + for task in normalized: + for action in task: + for plugin in self._plugins: + if plugin.can_handle(action): + try: + local_success = plugin.handle(action, task[action]) + if not local_success: + return False + except Exception as err: + self._log.error("An error was encountered while testing condition %s" % action) + self._log.debug(err) + return False + return True + + def normalize_tests(self, tests): + if isinstance(tests, str): + return [ { 'shell': tests } ] + elif isinstance(tests, dict): + return [ tests ] + elif isinstance(tests, list): + return map(lambda test: { 'shell': test } if isinstance(test, str) else test, tests) + else: + # TODO error + return [] diff --git a/sample.conf.yaml b/sample.conf.yaml new file mode 100644 index 0000000..e2a24d1 --- /dev/null +++ b/sample.conf.yaml @@ -0,0 +1,26 @@ +- defaults: + link: + relink: true + create: true + +- clean: ['~'] + +- conditional: + if: + tty: true + then: + - shell: + - 'echo "Running from a TTY"' + +- conditional: + if: + shell: "command -v python" + then: + - shell: + - 'echo "python is available"' + +- conditional: + if: "command -v foo" + else: + - shell: + - 'echo "foo is not available"'