Source code for cleanmymac.target

#
# author: Cosmin Basca
#
# Copyright 2015 Cosmin Basca
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#        http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import click
import os
import re
from pprint import pformat
from natsort import natsorted
from threading import Thread, Event
from time import sleep
from sarge import run, shell_format, Capture
from abc import ABCMeta, abstractmethod, abstractproperty
from cleanmymac.log import info, debug, error, warn, echo_info, echo_warn, echo_success
from cleanmymac.util import delete_dir_content, DirList, Dir, delete_dirs
from cleanmymac.constants import DESCRIBE_UPDATE, DESCRIBE_CLEAN, VALID_DESCRIBE_MESSAGES


# ----------------------------------------------------------------------------------------
#
# the base Target class
#
# ----------------------------------------------------------------------------------------
[docs]class Target(object): """ the main cleanup Target. This is an abstract class. :param config: a configuration dictionary :type config: dict :param update: perform the update before cleanup if True :type update: bool :param verbose: verbose output if True :type verbose: bool """ __metaclass__ = ABCMeta def __init__(self, config, update=False, verbose=False): self._config = config if isinstance(config, dict) else {} self._update = update self._verbose = verbose def _debug(self, msg, *arg): debug('[{0}] {1}'.format(click.style(str(self.__class__.__name__), fg='yellow'), msg)) @property def config(self): return self._config @abstractmethod
[docs] def update(self, **kwargs): """ the update operation :param kwargs: additional arguments :type kwargs: dict """ pass
@abstractmethod
[docs] def clean(self, **kwargs): """ the cleanup operation :param kwargs: additional arguments :type kwargs: dict """ pass
@abstractmethod
[docs] def describe(self): """ the description of the combined update and clean operations :return: a string describing the steps to be undertaken :rtype: str """ return ''
@staticmethod def __describe__(kind, message, fg=None): assert kind.lower() in VALID_DESCRIBE_MESSAGES max_len = max(map(len, VALID_DESCRIBE_MESSAGES)) fmt_string = '[ {0: <' + str(max_len+1) + '}] {1}' return click.style(fmt_string.format(kind.lower(), message), fg=fg) def _describe_update(self, message, fg='yellow'): return self.__describe__(DESCRIBE_UPDATE, message, fg=fg) def _describe_clean(self, message, fg='white'): return self.__describe__(DESCRIBE_CLEAN, message, fg=fg) def __call__(self, **kwargs): """ initiate the cleanup (and update if enabled) operations :param kwargs: additional arguments :type kwargs: dict """ if self._update: self._debug('update target') self.update(**kwargs) self._debug('clean target') self.clean(**kwargs)
# ---------------------------------------------------------------------------------------- # # the base Shell Target class # # ----------------------------------------------------------------------------------------
[docs]class ShellCommandTarget(Target): """ Class encapsulating general logic to execute cleanup operations based on predefined shell commands. This is an abstract class. :param config: a configuration dictionary :type config: dict :param update: perform the update before cleanup if True :type update: bool :param verbose: verbose output if True :type verbose: bool """ __metaclass__ = ABCMeta def __init__(self, config, update=False, verbose=False): super(ShellCommandTarget, self).__init__(config, update=update, verbose=verbose) self._env = self._config['env'] if 'env' in self._config else {} # prepare path if specified path = os.environ['PATH'] if 'PATH' in self._env: path = '{0}:{1}'.format(os.environ['PATH'], os.path.expanduser(self._env['PATH'])) self._env['PATH'] = path self._debug('target local env: {0}'.format(pformat(self._env))) @abstractproperty def update_commands(self): """ the shell commands executed on **update**. Each command is a string as expected by :func:`sarge.run` :return: a list of shell commands :rtype: list """ return [] @abstractproperty def clean_commands(self): """ the shell commands executed on **clean**. Each command is a string as expected by :func:`sarge.run` :return: a list of shell commands :rtype: list """ return [] def _run(self, commands): for cmd in commands: self._debug('run command "{0}"'.format(cmd)) try: with Capture() as err: with Capture() as out: if self._verbose: echo_success('running: {0}'.format(cmd)) command_done = Event() def redirect(): while not command_done.is_set(): try: for line in out: echo_info(line.strip()) except TypeError: pass try: for line in err: warn(line.strip()) except TypeError: pass sleep(0.05) p = run(cmd, stdout=out, stderr=err, env=self._env, async=True) if self._verbose: Thread(target=redirect).start() try: p.wait() finally: # make sure the console redirect thread is properly shutting down command_done.set() except OSError: error('command: "{0}" could not be executed (not found?)'.format(cmd)) @staticmethod def _describe(commands): return map(shell_format, commands)
[docs] def update(self, **kwargs): self._run(self.update_commands)
[docs] def clean(self, **kwargs): self._run(self.clean_commands)
[docs] def describe(self): commands_to_run = [] if self._update: commands_to_run += map(self._describe_update, self._describe(self.update_commands)) commands_to_run += map(self._describe_clean, self._describe(self.clean_commands)) return '\n'.join(commands_to_run)
# ---------------------------------------------------------------------------------------- # # a Shell Target class that can read it's description from a yaml file # # ----------------------------------------------------------------------------------------
[docs]class YamlShellCommandTarget(ShellCommandTarget): """ Class encapsulating logic to initialize and execute cleanup targets defined in **YAML** files. See predefined builtins in :mod:`cleanmymac.builtins`. :param config: a configuration dictionary :type config: dict :param update: perform the update before cleanup if True :type update: bool :param verbose: verbose output if True :type verbose: bool """ def __init__(self, config, update=False, verbose=False): self._spec = config['spec'] self._debug('spec: {0}'.format(pformat(self._spec))) super(YamlShellCommandTarget, self).__init__(config, update=update, verbose=verbose) @property def update_commands(self): return self._spec['update_commands'] @property def clean_commands(self): return self._spec['clean_commands']
# ---------------------------------------------------------------------------------------- # # a Directory based Target class # # ----------------------------------------------------------------------------------------
[docs]class DirTarget(Target): """ Class encapsulating the logic to execute directory based cleanup operations. The main operation consists of identifying and removing all matching directories in a given path with the exception of the most recent version. This is an abstract class. .. warning:: if `pattern` is not specified: all files and folders in `dir` will be removed :param config: a configuration dictionary :type config: dict :param update: perform the update before cleanup if True :type update: bool :param verbose: verbose output if True :type verbose: bool """ __metaclass__ = ABCMeta def __init__(self, config, update=False, verbose=False): super(DirTarget, self).__init__(config, update=update, verbose=verbose) @property def update_message(self): """ message to be displayed during the update operation :return: the message :rtype: str """ return 'update not supported for "dir" targets'
[docs] def update(self, **kwargs): if self._verbose and self.update_message: echo_info(self.update_message)
def _to_remove(self): for entry in self.entries: self._debug('check entry "{0}" to clean'.format(entry['dir'])) _dir = os.path.expanduser(entry['dir']) if 'pattern' in entry: _pattern = entry['pattern'] dirs = [os.path.join(_dir, d) for d in os.listdir(_dir) if os.path.isdir(os.path.join(_dir, d)) and re.match(_pattern, d)] dirs = natsorted(dirs, reverse=True) dir_list = DirList(dirs[1:]) self._debug('\tremove multiple directories: {0}'.format(dir_list.dirs)) yield dir_list else: self._debug('\tremove single directory: {0}'.format(_dir)) yield Dir(_dir)
[docs] def clean(self, **kwargs): for entry in self._to_remove(): if isinstance(entry, DirList): if self._verbose: echo_warn('delete folders: {0}'.format(pformat(entry.dirs))) delete_dirs(entry) elif isinstance(entry, Dir): if self._verbose: echo_warn('delete folder contents: {0}'.format(entry.path)) delete_dir_content(entry)
[docs] def describe(self): msgs = [] if self._update and self.update_message: msgs.append(self._describe_update(self.update_message)) nothing_to_remove = True for entry in self._to_remove(): if isinstance(entry, DirList) and entry.dirs: msgs.append(self._describe_clean('delete folders: {0}'.format(pformat(entry.dirs)))) nothing_to_remove = False elif isinstance(entry, Dir): msgs.append(self._describe_clean('delete folder contents: {0}'.format(entry.path))) nothing_to_remove = False if nothing_to_remove: msgs.append(self._describe_clean('There are no folders to delete/clean')) return '\n'.join(msgs)
@abstractproperty def entries(self): """ the list of entries (pairs of path: regex pattern) to scan for cleanup. Keeps latest versions only. :return: a list of entries path:pattern pairs :rtype: list """ return []
# ---------------------------------------------------------------------------------------- # # a Dir Target class that can read it's description from a yaml file # # ----------------------------------------------------------------------------------------
[docs]class YamlDirTarget(DirTarget): """ Class encapsulating the logic to execute directory based cleanup operations. The main operation consists of identifying and removing all matching directories in a given path with the exception of the most recent version. This concrete implementation allows for the specification of entries in a **YAML** configuration file. See predefined builtins in :mod:`cleanmymac.builtins`. :param config: a configuration dictionary :type config: dict :param update: perform the update before cleanup if True :type update: bool :param verbose: verbose output if True :type verbose: bool """ def __init__(self, config, update=False, verbose=False): self._spec = config['spec'] self._debug('spec: {0}'.format(pformat(self._spec))) super(YamlDirTarget, self).__init__(config, update=update, verbose=verbose) @property def entries(self): return self._spec['entries'] @property def update_message(self): return self._spec['update_message'] if 'update_message' in self._spec else ''