Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add generic conditional container plugin. #326

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dotbot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")):
Expand Down
27 changes: 27 additions & 0 deletions dotbot/condition.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions dotbot/conditions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .shell import ShellCondition
from .tty import TtyCondition
20 changes: 20 additions & 0 deletions dotbot/conditions/shell.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions dotbot/conditions/tty.py
Original file line number Diff line number Diff line change
@@ -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())
3 changes: 2 additions & 1 deletion dotbot/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions dotbot/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .clean import Clean
from .conditional import Conditional
from .create import Create
from .link import Link
from .shell import Shell
43 changes: 43 additions & 0 deletions dotbot/plugins/conditional.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note this currently has the same issue as the similar third-party plugins (scoping defaults to the child tasks). I think the best approach would be to update dispatcher constructor to allow passing in (and therefore reusing) a context.

Other options considered:

  • Reusing the parent dispatcher by exposing it through context
  • Modify the task list by prepending a default directive. After completion, get the child context defaults and call set_defaults on parent context.

In either case, I also think it's worth adding a configuration option to this plugin to control whether defaults are scoped to the child tasks or not.

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)
40 changes: 40 additions & 0 deletions dotbot/tester.py
Original file line number Diff line number Diff line change
@@ -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 []
26 changes: 26 additions & 0 deletions sample.conf.yaml
Original file line number Diff line number Diff line change
@@ -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"'