From fe5b96a43226d72cbd9a8462a779d6d21d31cdc8 Mon Sep 17 00:00:00 2001 From: Daniel Jaouen Date: Wed, 19 Feb 2014 14:48:59 -0500 Subject: [PATCH 1/4] Add module homebrew_cask --- packaging/homebrew_cask | 511 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 packaging/homebrew_cask diff --git a/packaging/homebrew_cask b/packaging/homebrew_cask new file mode 100644 index 00000000000..20241f2e5cd --- /dev/null +++ b/packaging/homebrew_cask @@ -0,0 +1,511 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2013, Daniel Jaouen +# +# This module 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 software 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 software. If not, see . + +DOCUMENTATION = ''' +--- +module: homebrew_cask +author: Daniel Jaouen +short_description: Install/uninstall homebrew casks. +description: + - Manages Homebrew casks. +version_added: "1.5" +options: + name: + description: + - name of cask to install/remove + required: true + state: + description: + - state of the cask + choices: [ 'installed', 'uninstalled' ] + required: false + default: present +''' +EXAMPLES = ''' +- homebrew_cask: name=alfred state=present +- homebrew_cask: name=alfred state=absent +''' + +import os.path +import re + + +# exceptions -------------------------------------------------------------- {{{ +class HomebrewCaskException(Exception): + pass +# /exceptions ------------------------------------------------------------- }}} + + +# utils ------------------------------------------------------------------- {{{ +def _create_regex_group(s): + lines = (line.strip() for line in s.split('\n') if line.strip()) + chars = filter(None, (line.split('#')[0].strip() for line in lines)) + group = r'[^' + r''.join(chars) + r']' + return re.compile(group) +# /utils ------------------------------------------------------------------ }}} + + +class HomebrewCask(object): + '''A class to manage Homebrew casks.''' + + # class regexes ------------------------------------------------ {{{ + VALID_PATH_CHARS = r''' + \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) + \s # spaces + : # colons + {sep} # the OS-specific path separator + - # dashes + '''.format(sep=os.path.sep) + + VALID_BREW_PATH_CHARS = r''' + \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) + \s # spaces + {sep} # the OS-specific path separator + - # dashes + '''.format(sep=os.path.sep) + + VALID_CASK_CHARS = r''' + \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) + - # dashes + ''' + + INVALID_PATH_REGEX = _create_regex_group(VALID_PATH_CHARS) + INVALID_BREW_PATH_REGEX = _create_regex_group(VALID_BREW_PATH_CHARS) + INVALID_CASK_REGEX = _create_regex_group(VALID_CASK_CHARS) + # /class regexes ----------------------------------------------- }}} + + # class validations -------------------------------------------- {{{ + @classmethod + def valid_path(cls, path): + ''' + `path` must be one of: + - list of paths + - a string containing only: + - alphanumeric characters + - dashes + - spaces + - colons + - os.path.sep + ''' + + if isinstance(path, basestring): + return not cls.INVALID_PATH_REGEX.search(path) + + try: + iter(path) + except TypeError: + return False + else: + paths = path + return all(cls.valid_brew_path(path_) for path_ in paths) + + @classmethod + def valid_brew_path(cls, brew_path): + ''' + `brew_path` must be one of: + - None + - a string containing only: + - alphanumeric characters + - dashes + - spaces + - os.path.sep + ''' + + if brew_path is None: + return True + + return ( + isinstance(brew_path, basestring) + and not cls.INVALID_BREW_PATH_REGEX.search(brew_path) + ) + + @classmethod + def valid_cask(cls, cask): + '''A valid cask is either None or alphanumeric + backslashes.''' + + if cask is None: + return True + + return ( + isinstance(cask, basestring) + and not cls.INVALID_CASK_REGEX.search(cask) + ) + + @classmethod + def valid_state(cls, state): + ''' + A valid state is one of: + - installed + - absent + ''' + + if state is None: + return True + else: + return ( + isinstance(state, basestring) + and state.lower() in ( + 'installed', + 'absent', + ) + ) + + @classmethod + def valid_module(cls, module): + '''A valid module is an instance of AnsibleModule.''' + + return isinstance(module, AnsibleModule) + # /class validations ------------------------------------------- }}} + + # class properties --------------------------------------------- {{{ + @property + def module(self): + return self._module + + @module.setter + def module(self, module): + if not self.valid_module(module): + self._module = None + self.failed = True + self.message = 'Invalid module: {0}.'.format(module) + raise HomebrewCaskException(self.message) + + else: + self._module = module + return module + + @property + def path(self): + return self._path + + @path.setter + def path(self, path): + if not self.valid_path(path): + self._path = [] + self.failed = True + self.message = 'Invalid path: {0}.'.format(path) + raise HomebrewCaskException(self.message) + + else: + if isinstance(path, basestring): + self._path = path.split(':') + else: + self._path = path + + return path + + @property + def brew_path(self): + return self._brew_path + + @brew_path.setter + def brew_path(self, brew_path): + if not self.valid_brew_path(brew_path): + self._brew_path = None + self.failed = True + self.message = 'Invalid brew_path: {0}.'.format(brew_path) + raise HomebrewCaskException(self.message) + + else: + self._brew_path = brew_path + return brew_path + + @property + def params(self): + return self._params + + @params.setter + def params(self, params): + self._params = self.module.params + return self._params + + @property + def current_cask(self): + return self._current_cask + + @current_cask.setter + def current_cask(self, cask): + if not self.valid_cask(cask): + self._current_cask = None + self.failed = True + self.message = 'Invalid cask: {0}.'.format(cask) + raise HomebrewCaskException(self.message) + + else: + self._current_cask = cask + return cask + # /class properties -------------------------------------------- }}} + + def __init__(self, module, path=None, casks=None, state=None): + self._setup_status_vars() + self._setup_instance_vars(module=module, path=path, casks=casks, + state=state) + + self._prep() + + # prep --------------------------------------------------------- {{{ + def _setup_status_vars(self): + self.failed = False + self.changed = False + self.changed_count = 0 + self.unchanged_count = 0 + self.message = '' + + def _setup_instance_vars(self, **kwargs): + for key, val in kwargs.iteritems(): + setattr(self, key, val) + + def _prep(self): + self._prep_path() + self._prep_brew_path() + + def _prep_path(self): + if not self.path: + self.path = ['/usr/local/bin'] + + def _prep_brew_path(self): + if not self.module: + self.brew_path = None + self.failed = True + self.message = 'AnsibleModule not set.' + raise HomebrewCaskException(self.message) + + self.brew_path = self.module.get_bin_path( + 'brew', + required=True, + opt_dirs=self.path, + ) + if not self.brew_path: + self.brew_path = None + self.failed = True + self.message = 'Unable to locate homebrew executable.' + raise HomebrewCaskException('Unable to locate homebrew executable.') + + return self.brew_path + + def _status(self): + return (self.failed, self.changed, self.message) + # /prep -------------------------------------------------------- }}} + + def run(self): + try: + self._run() + except HomebrewCaskException: + pass + + if not self.failed and (self.changed_count + self.unchanged_count > 1): + self.message = "Changed: %d, Unchanged: %d" % ( + self.changed_count, + self.unchanged_count, + ) + (failed, changed, message) = self._status() + + return (failed, changed, message) + + # checks ------------------------------------------------------- {{{ + def _current_cask_is_installed(self): + if not self.valid_cask(self.current_cask): + self.failed = True + self.message = 'Invalid cask: {0}.'.format(self.current_cask) + raise HomebrewCaskException(self.message) + + cmd = [self.brew_path, 'cask', 'list'] + rc, out, err = self.module.run_command(cmd) + + if rc == 0: + casks = [cask_.strip() for cask_ in out.split('\n') if cask_.strip()] + return self.current_cask in casks + else: + self.failed = True + self.message = err.strip() + raise HomebrewCaskException(self.message) + # /checks ------------------------------------------------------ }}} + + # commands ----------------------------------------------------- {{{ + def _run(self): + if self.state == 'installed': + return self._install_casks() + elif self.state == 'absent': + return self._uninstall_casks() + + if self.command: + return self._command() + + # updated -------------------------------- {{{ + def _update_homebrew(self): + rc, out, err = self.module.run_command([ + self.brew_path, + 'update', + ]) + if rc == 0: + if out and isinstance(out, basestring): + already_updated = any( + re.search(r'Already up-to-date.', s.strip(), re.IGNORECASE) + for s in out.split('\n') + if s + ) + if not already_updated: + self.changed = True + self.message = 'Homebrew updated successfully.' + else: + self.message = 'Homebrew already up-to-date.' + + return True + else: + self.failed = True + self.message = err.strip() + raise HomebrewCaskException(self.message) + # /updated ------------------------------- }}} + + # installed ------------------------------ {{{ + def _install_current_cask(self): + if not self.valid_cask(self.current_cask): + self.failed = True + self.message = 'Invalid cask: {0}.'.format(self.current_cask) + raise HomebrewCaskException(self.message) + + if self._current_cask_is_installed(): + self.unchanged_count += 1 + self.message = 'Cask already installed: {0}'.format( + self.current_cask, + ) + return True + + if self.module.check_mode: + self.changed = True + self.message = 'Cask would be installed: {0}'.format( + self.current_cask + ) + raise HomebrewCaskException(self.message) + + cmd = [opt + for opt in (self.brew_path, 'cask', 'install', self.current_cask) + if opt] + + rc, out, err = self.module.run_command(cmd) + + if self._current_cask_is_installed(): + self.changed_count += 1 + self.changed = True + self.message = 'Cask installed: {0}'.format(self.current_cask) + return True + else: + self.failed = True + self.message = err.strip() + raise HomebrewCaskException(self.message) + + def _install_casks(self): + for cask in self.casks: + self.current_cask = cask + self._install_current_cask() + + return True + # /installed ----------------------------- }}} + + # uninstalled ---------------------------- {{{ + def _uninstall_current_cask(self): + if not self.valid_cask(self.current_cask): + self.failed = True + self.message = 'Invalid cask: {0}.'.format(self.current_cask) + raise HomebrewCaskException(self.message) + + if not self._current_cask_is_installed(): + self.unchanged_count += 1 + self.message = 'Cask already uninstalled: {0}'.format( + self.current_cask, + ) + return True + + if self.module.check_mode: + self.changed = True + self.message = 'Cask would be uninstalled: {0}'.format( + self.current_cask + ) + raise HomebrewCaskException(self.message) + + cmd = [opt + for opt in (self.brew_path, 'cask', 'uninstall', self.current_cask) + if opt] + + rc, out, err = self.module.run_command(cmd) + + if not self._current_cask_is_installed(): + self.changed_count += 1 + self.changed = True + self.message = 'Cask uninstalled: {0}'.format(self.current_cask) + return True + else: + self.failed = True + self.message = err.strip() + raise HomebrewCaskException(self.message) + + def _uninstall_casks(self): + for cask in self.casks: + self.current_cask = cask + self._uninstall_current_cask() + + return True + # /uninstalled ----------------------------- }}} + # /commands ---------------------------------------------------- }}} + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict(aliases=["cask"], required=False), + path=dict(required=False), + state=dict( + default="present", + choices=[ + "present", "installed", + "absent", "removed", "uninstalled", + ], + ), + ), + supports_check_mode=True, + ) + p = module.params + + if p['name']: + casks = p['name'].split(',') + else: + casks = None + + path = p['path'] + if path: + path = path.split(':') + else: + path = ['/usr/local/bin'] + + state = p['state'] + if state in ('present', 'installed'): + state = 'installed' + if state in ('absent', 'removed', 'uninstalled'): + state = 'absent' + + brew_cask = HomebrewCask(module=module, path=path, casks=casks, + state=state) + (failed, changed, message) = brew_cask.run() + if failed: + module.fail_json(msg=message) + else: + module.exit_json(changed=changed, msg=message) + +# this is magic, see lib/ansible/module_common.py +#<> +main() From c9b8877cc6e68667fd3858acdd875e131fa9cc70 Mon Sep 17 00:00:00 2001 From: Daniel Jaouen Date: Wed, 19 Feb 2014 18:49:25 -0500 Subject: [PATCH 2/4] Handle homebrew_cask "nothing to list" corner case. --- packaging/homebrew_cask | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packaging/homebrew_cask b/packaging/homebrew_cask index 20241f2e5cd..96fd5cfef1c 100644 --- a/packaging/homebrew_cask +++ b/packaging/homebrew_cask @@ -327,7 +327,9 @@ class HomebrewCask(object): cmd = [self.brew_path, 'cask', 'list'] rc, out, err = self.module.run_command(cmd) - if rc == 0: + if 'nothing to list' in out: + return True + elif rc == 0: casks = [cask_.strip() for cask_ in out.split('\n') if cask_.strip()] return self.current_cask in casks else: From fb526e1afb0fbc39053364288ca16e9ba5a8930f Mon Sep 17 00:00:00 2001 From: Daniel Jaouen Date: Wed, 19 Feb 2014 18:57:36 -0500 Subject: [PATCH 3/4] homebrew_cask: return False instead of True when nothing to list. --- packaging/homebrew_cask | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/homebrew_cask b/packaging/homebrew_cask index 96fd5cfef1c..ec9c47ecfb9 100644 --- a/packaging/homebrew_cask +++ b/packaging/homebrew_cask @@ -328,7 +328,7 @@ class HomebrewCask(object): rc, out, err = self.module.run_command(cmd) if 'nothing to list' in out: - return True + return False elif rc == 0: casks = [cask_.strip() for cask_ in out.split('\n') if cask_.strip()] return self.current_cask in casks From fe362b79fd0de75fc2d469d2c7d63404bfe5852c Mon Sep 17 00:00:00 2001 From: Daniel Jaouen Date: Wed, 19 Feb 2014 18:59:33 -0500 Subject: [PATCH 4/4] homebrew_cask: check err instead of out for "nothing to list". --- packaging/homebrew_cask | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/homebrew_cask b/packaging/homebrew_cask index ec9c47ecfb9..9954da47a26 100644 --- a/packaging/homebrew_cask +++ b/packaging/homebrew_cask @@ -327,7 +327,7 @@ class HomebrewCask(object): cmd = [self.brew_path, 'cask', 'list'] rc, out, err = self.module.run_command(cmd) - if 'nothing to list' in out: + if 'nothing to list' in err: return False elif rc == 0: casks = [cask_.strip() for cask_ in out.split('\n') if cask_.strip()]