]> kaliko git repositories - mpd-sima.git/commitdiff
Add conf management and cli parsing
authorkaliko <efrim@azylum.org>
Sun, 22 Sep 2013 11:36:48 +0000 (13:36 +0200)
committerkaliko <efrim@azylum.org>
Sun, 22 Sep 2013 11:36:48 +0000 (13:36 +0200)
README [new file with mode: 0644]
launch
sima/core.py
sima/utils/__init__.py [new file with mode: 0644]
sima/utils/config.py [new file with mode: 0644]
sima/utils/startopt.py [new file with mode: 0644]
sima/utils/utils.py [new file with mode: 0644]

diff --git a/README b/README
new file mode 100644 (file)
index 0000000..ec5133d
--- /dev/null
+++ b/README
@@ -0,0 +1,5 @@
+Design for python >= 3.3
+
+Requires python-musicpd:
+
+               http://media.kaliko.me/src/musicpd/
diff --git a/launch b/launch
index d6544b7c8acd6f09ccecf866f4c5ff48dfcad89b..f3603cb4be3e89042ab2200c88baf78dd4210a68 100755 (executable)
--- a/launch
+++ b/launch
@@ -1,27 +1,54 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 
+import logging
+##
+
+from sima import core
+from sima.plugins.crop import Crop
+from sima.lib.logger import set_logger
+from sima.utils.config import ConfMan
+from sima.utils.startopt import StartOpt
+from sima.utils.utils import exception_log
+##
+
+
 def main():
-    from logging import getLogger
-    ##
-    from sima import core
-    from sima.plugins.crop import Crop
-    from sima.lib.logger import set_logger
-    ##
-    set_logger(log_level='debug')
-    logger = getLogger('sima')
-    m = core.Sima()
-    m.register_plugin(Crop)
+    """Entry point, deal w/ CLI and starts application
+    """
+    info = dict({'version': core.__version__,})
+    # StartOpt gathers options from command line call (in StartOpt().options)
+    sopt = StartOpt(info)
+    # set logger
+    set_logger(level='debug')
+    logger = logging.getLogger('sima')
+    cli_loglevel = getattr(logging,
+                           sopt.options.get('verbosity', 'warning').upper())
+    logger.setLevel(cli_loglevel)
+    # loads configuration
+    conf_manager = ConfMan(logger, sopt.options)
+    config = conf_manager.config
+    logger.setLevel(getattr(logging,
+                    config.get('log', 'verbosity').upper()))  # pylint: disable=E1103
+
+    logger.debug('Command line say: {0}'.format(sopt.options))
+    logger.info('Starting...')
+    sima = core.Sima()
+    sima.register_plugin(Crop)
     try:
-        m.run()
+        sima.run()
     except KeyboardInterrupt:
         logger.info('Caught KeyboardInterrupt, stopping')
-        m.shutdown()
+        sima.shutdown()
 
 
 # Script starts here
 if __name__ == '__main__':
-    main()
+    # pylint: disable=broad-except
+    try:
+        main()
+    except Exception:
+        exception_log()
 
 
 # VIM MODLINE
index f07c3e94c788b90723dff4a4fec574532eca0a47..c751e5ba2b63a6bd0cafb2f7c79dd62770bc118a 100644 (file)
@@ -45,15 +45,14 @@ class Sima(object):
         """Dispatching callbacks to plugins
         """
         self.log.debug(self.player.status())
+        self.log.info(self.player.current)
         while 42:
             # hanging here untill a monitored event is raised in the player
             changed = self.player.monitor()
-            self.log.debug(self.player.current)
             if 'playlist' in changed:
                 self.foreach_plugin('callback_playlist')
             if 'player' in changed:
-                pass
-
+                self.log.info(self.player.current)
 
 
 # VIM MODLINE
diff --git a/sima/utils/__init__.py b/sima/utils/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/sima/utils/config.py b/sima/utils/config.py
new file mode 100644 (file)
index 0000000..84367d6
--- /dev/null
@@ -0,0 +1,247 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2009, 2010, 2011, 2013 Jack Kaliko <kaliko@azylum.org>
+#
+#  This file is part of sima
+#
+#  sima 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.
+#
+#  sima 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 sima.  If not, see <http://www.gnu.org/licenses/>.
+#
+#
+
+"""
+Deal with configuration and data files.
+Parse configuration file and set defaults for missing options.
+"""
+
+# IMPORTS
+import configparser
+import sys
+
+from configparser import Error
+from os import (makedirs, environ, stat, chmod)
+from os.path import (join, isdir, isfile)
+from stat import (S_IMODE, ST_MODE, S_IRWXO, S_IRWXG)
+
+from . import utils
+
+# DEFAULTS
+DIRNAME = 'mpd_sima'
+CONF_FILE = 'mpd_sima.cfg'
+
+DEFAULT_CONF = {
+        'MPD': {
+            'host': "localhost",
+            'password': "false",
+            'port': "6600"},
+        'sima': {
+            'similarity': "15",
+            'dynamic': "10",
+            'queue_mode': "track", #TODO control values
+            'user_db': "false",
+            'history_duration': "8",
+            'queue_length': "1",
+            'track_to_add': "1",
+            'album_to_add': "1",
+            'consume': "0",
+            'single_album': "false",
+            'check_new_version':"false",},
+        'daemon':{
+            'daemon': "false",
+            'pidfile': "",},
+        'log': {
+            'verbosity': "info"}}
+#
+
+
+class ConfMan(object):#CONFIG MANAGER CLASS
+    """
+    Configuration manager.
+    Default configuration is stored in DEFAULT_CONF dictionnary.
+    First init_config() run to get config from file.
+    Then control_conf() is run and retrieve configuration from defaults if not
+    set in conf files.
+    These settings are then updated with command line options with
+    supersedes_config_with_cmd_line_options().
+
+    Order of priority for the origin of an option is then (lowest to highest):
+        * DEFAULT_CONF
+        * Env. Var for MPD host, port and password
+        * configuration file (overrides previous)
+        * command line options (overrides previous)
+    """
+
+    def __init__(self, logger, options=None):
+        # options settings priority:
+        # defauts < conf. file < command line
+        self.conf_file = options.get('conf_file')
+        self.config = None
+        self.defaults = dict(DEFAULT_CONF)
+        self.startopt = options
+        ## Sima sqlite DB
+        self.userdb_file = None
+
+        self.log = logger
+        ## INIT CALLS
+        self.use_envar()
+        self.init_config()
+        self.control_conf()
+        self.supersedes_config_with_cmd_line_options()
+
+    def get_pw(self):
+        try:
+            self.config.getboolean('MPD', 'password')
+            self.log.debug('No password set, proceeding without ' +
+                           'authentication...')
+            return None
+        except ValueError:
+            # ValueError if password not a boolean, hence an actual password.
+            pw = self.config.get('MPD', 'password')
+            if not pw:
+                self.log.debug('Password set as an empty string.')
+                return None
+            return pw
+
+    def control_mod(self):
+        """
+        Controls conf file permissions.
+        """
+        mode = S_IMODE(stat(self.conf_file)[ST_MODE])
+        self.log.debug('file permision is: %o' % mode)
+        if mode & S_IRWXO or mode & S_IRWXG:
+            self.log.warning('File is readable by "other" and/or' +
+                             ' "group" (actual permission %o octal).' %
+                             mode)
+            self.log.warning('Consider setting permissions' +
+                             ' to 600 octal.')
+
+    def supersedes_config_with_cmd_line_options(self):
+        """Updates defaults settings with command line options"""
+        for sec in self.config.sections():
+            for opt in self.config.options(sec):
+                if opt in list(self.startopt.keys()):
+                    self.config.set(sec, opt, str(self.startopt.get(opt)))
+
+    def use_envar(self):
+        """Use MPD en.var. to set defaults"""
+        mpd_host, mpd_port, passwd = utils.get_mpd_environ()
+        if mpd_host:
+            self.log.info('Env. variable MPD_HOST set to "%s"' % mpd_host)
+            self.defaults['MPD']['host'] = mpd_host
+        if passwd:
+            self.log.info('Env. variable MPD_HOST contains password.')
+            self.defaults['MPD']['password'] = passwd
+        if mpd_port:
+            self.log.info('Env. variable MPD_PORT set to "%s".'
+                                  % mpd_port)
+            self.defaults['MPD']['port'] = mpd_port
+
+    def control_conf(self):
+        """Get through options/values and set defaults if not in conf file."""
+        # Control presence of obsolete settings
+        for option in ['history', 'history_length', 'top_tracks']:
+            if self.config.has_option('sima', option):
+                self.log.warning('Obsolete setting found in conf file: "%s"'
+                        % option)
+        # Setting default if not specified
+        for section in DEFAULT_CONF.keys():
+            if section not in self.config.sections():
+                self.log.debug('[%s] NOT in conf file' % section)
+                self.config.add_section(section)
+                for option in self.defaults[section]:
+                    self.config.set(section,
+                            option,
+                            self.defaults[section][option])
+                    self.log.debug(
+                            'Setting option with default value: %s = %s' %
+                            (option, self.defaults[section][option]))
+            elif section in self.config.sections():
+                self.log.debug('[%s] present in conf file' % section)
+                for option in self.defaults[section]:
+                    if self.config.has_option(section, option):
+                        #self.log.debug(u'option "%s" set to "%s" in conf. file' %
+                        #              (option, self.config.get(section, option)))
+                        pass
+                    else:
+                        self.log.debug(
+                                'Option "%s" missing in section "%s"' %
+                                (option, section))
+                        self.log.debug('=> setting default "%s" (may not suit you…)' %
+                                       self.defaults[section][option])
+                        self.config.set(section, option,
+                                        self.defaults[section][option])
+
+    def init_config(self):
+        """
+        Use XDG directory standard if exists
+        else use "HOME/(.config|.local/share)/mpd_sima/"
+        http://standards.freedesktop.org/basedir-spec/basedir-spec-0.6.html
+        """
+
+        homedir = environ.get('HOME')
+
+        if environ.get('XDG_DATA_HOME'):
+            data_dir = join(environ.get('XDG_DATA_HOME'), DIRNAME)
+        elif self.startopt.get('var_dir'):
+            # If var folder is provided via CLI set data_dir accordingly
+            data_dir = join(self.startopt.get('var_dir'))
+        elif (homedir and isdir(homedir) and homedir not in ['/']):
+            data_dir = join(homedir, '.local', 'share', DIRNAME)
+        else:
+            self.log.error('Can\'t find a suitable location for data folder (XDG_DATA_HOME)')
+            self.log.error('Please use "--var_dir" to set a proper location')
+            sys.exit(1)
+
+        if not isdir(data_dir):
+            makedirs(data_dir)
+            chmod(data_dir, 0o700)
+
+        if self.startopt.get('conf_file'):
+            # No need to handle conf file location
+            pass
+        elif environ.get('XDG_CONFIG_HOME'):
+            conf_dir = join(environ.get('XDG_CONFIG_HOME'), DIRNAME)
+        elif (homedir and isdir(homedir) and homedir not in ['/']):
+            conf_dir = join(homedir, '.config', DIRNAME)
+            # Create conf_dir if necessary
+            if not isdir(conf_dir):
+                makedirs(conf_dir)
+                chmod(conf_dir, 0o700)
+            self.conf_file = join(conf_dir, CONF_FILE)
+        else:
+            self.log.error('Can\'t find a suitable location for config folder (XDG_CONFIG_HOME)')
+            self.log.error('Please use "--config" to locate the conf file')
+            sys.exit(1)
+
+        self.userdb_file = join(data_dir, 'sima.db')
+
+        config = configparser.SafeConfigParser()
+
+        # If no conf file present, uses defaults
+        if not isfile(self.conf_file):
+            self.config = config
+            return
+
+        self.log.info('Loading configuration from:  %s' % self.conf_file)
+        self.control_mod()
+
+        try:
+            config.read(self.conf_file)
+        except Error as err:
+            self.log.error(err)
+            sys.exit(1)
+
+        self.config = config
+
+# VIM MODLINE
+# vim: ai ts=4 sw=4 sts=4 expandtab
diff --git a/sima/utils/startopt.py b/sima/utils/startopt.py
new file mode 100644 (file)
index 0000000..988e67d
--- /dev/null
@@ -0,0 +1,155 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2009, 2010, 2011, 2012, 2013 Jack Kaliko <kaliko@azylum.org>
+#
+#  This file is part of sima
+#
+#  sima 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.
+#
+#  sima 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 sima.  If not, see <http://www.gnu.org/licenses/>.
+#
+#
+
+import sys
+from argparse import (ArgumentParser, SUPPRESS)
+
+
+from .utils import Obsolete, Wfile, Rfile, Wdir
+
+USAGE = """USAGE:  %prog [--help] [options]"""
+DESCRIPTION = """
+sima automagicaly queue new tracks in MPD playlist.
+All command line options will override their equivalent in configuration
+file.
+"""
+
+
+def clean_dict(to_clean):
+    """Remove items which values are set to None/False"""
+    for k in list(to_clean.keys()):
+        if not to_clean.get(k):
+            to_clean.pop(k)
+
+
+# OPTIONS LIST
+# pop out 'sw' value before creating Parser object.
+# PAY ATTENTION:
+#   If an option has to override its dual in conf file, the destination
+#   identifier "dest" is to be named after that option in the conf file.
+#   The supersedes_config_with_cmd_line_options method in ConfMan() (config.py)
+#   is looking for command line option names identical to config file option
+#   name it is meant to override.
+OPTS = [
+    {
+        'sw':['-l', '--log'],
+        'type': str,
+        'dest': 'logfile',
+        'action': Wfile,
+        'help': 'file to log message to, default is stdout/stderr'},
+    {
+        'sw':['-v', '--log-level'],
+        'type': str,
+        'dest': 'verbosity',
+        'choices': ['debug', 'info', 'warning', 'error'],
+        'help': 'file to log message to, default is stdout/stderr'},
+    {
+        'sw': ['-p', '--pid'],
+        'dest': 'pidfile',
+        'action': Wfile,
+        'help': 'file to save PID to, default is not to store pid'},
+    {
+        'sw': ['-d', '--daemon'],
+        'dest': 'daemon',
+        'action': 'store_true',
+        'help': 'Daemonize process.'},
+    {
+        'sw': ['-S', '--host'],
+        'dest': 'host',
+        'help': 'Host MPD in running on (IP or FQDN)'},
+    {
+        'sw': ['-P', '--port'],
+        'type': int,
+        'dest': 'port',
+        'help': 'Port MPD in listening on'},
+    {
+        'sw':['-c', '--config'],
+        'dest': 'conf_file',
+        'action': Rfile,
+        'help': 'Configuration file to load'},
+    {
+        'sw':['--var_dir'],
+        'dest': 'var_dir',
+        'action': Wdir,
+        'help': 'Directory to store var content (ie. database)'},
+    {
+        'sw': ['--create-db'],
+        'action': 'store_true',
+        'dest': 'create_db',
+        'help': '''Create database and exit, use destination
+                   specified in --var_dir or standard location.'''},
+    {
+        'sw':['--queue-mode', '-q'],
+        'dest': 'queue_mode',
+        'choices': ['track', 'top', 'album'],
+        #'help': 'Queue mode in [track, top, album]',
+        'help': SUPPRESS, },
+    {
+        'sw':['--purge_history'],
+        'action': 'store_true',
+        'dest': 'do_purge_history',
+        'help': SUPPRESS},
+]
+
+
+class StartOpt(object):
+    """Command line management.
+    """
+
+    def __init__(self, script_info,):
+        self.info = dict(script_info)
+        self.options = dict()
+        self.main()
+
+    def declare_opts(self):
+        """
+        Declare options in ArgumentParser object.
+        """
+        self.parser = ArgumentParser(description=DESCRIPTION,
+                                   usage='%(prog)s [options]',
+                                   prog='mpd_sima',
+                                   epilog='Happy Listening',
+                )
+        self.parser.add_argument('--version', action='version',
+                version='%(prog)s {version}'.format(**self.info))
+        # Add all options declare in OPTS
+        for opt in OPTS:
+            opt_names = opt.pop('sw')
+            self.parser.add_argument(*opt_names, **opt)
+
+    def main(self):
+        """
+        Look for env. var and parse command line.
+        """
+        self.declare_opts()
+        options = vars(self.parser.parse_args())
+        # Set log file to os.devnull in daemon mode to avoid logging to
+        # std(out|err).
+        # TODO: Probably useless. To be checked
+        #if options.__dict__.get('daemon', False) and \
+        #        not options.__dict__.get('logfile', False):
+        #    options.__dict__['logfile'] = devnull
+        self.options.update(options)
+        clean_dict(self.options)
+
+
+# VIM MODLINE
+# vim: ai ts=4 sw=4 sts=4 expandtab
diff --git a/sima/utils/utils.py b/sima/utils/utils.py
new file mode 100644 (file)
index 0000000..017af9f
--- /dev/null
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2010, 2011, 2013 Jack Kaliko <kaliko@azylum.org>
+#
+#  This file is part of sima
+#
+#  sima 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.
+#
+#  sima 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 sima.  If not, see <http://www.gnu.org/licenses/>.
+#
+#
+"""generic tools and utilitaries for sima
+"""
+
+import traceback
+import sys
+
+from argparse import (ArgumentError, Action)
+from os import (environ, access, getcwd, W_OK, R_OK)
+from os.path import (dirname, isabs, join, normpath, exists, isdir, isfile)
+
+def get_mpd_environ():
+    """
+    Retrieve MPD env. var.
+    """
+    passwd = host = None
+    mpd_host_env = environ.get('MPD_HOST')
+    if mpd_host_env:
+        # If password is set:
+        # mpd_host_env = ['pass', 'host'] because MPD_HOST=pass@host
+        mpd_host_env = mpd_host_env.split('@')
+        mpd_host_env.reverse()
+        host = mpd_host_env[0]
+        if len(mpd_host_env) > 1 and mpd_host_env[1]:
+            passwd = mpd_host_env[1]
+    return (host, environ.get('MPD_PORT', None), passwd)
+
+def normalize_path(path):
+    """Get absolute path
+    """
+    if not isabs(path):
+        return normpath(join(getcwd(), path))
+    return path
+
+def exception_log():
+    """Log unknown exceptions"""
+    import logging
+    log = logging.getLogger('sima')
+    log.error('Unhandled Exception!!!')
+    log.error(''.join(traceback.format_exc()))
+    log.info('Please report the previous message'
+             ' along with some log entries right before the crash.')
+    log.info('thanks for your help :)')
+    log.info('Quiting now!')
+    sys.exit(1)
+
+
+# ArgParse Callbacks
+class Obsolete(Action):
+    # pylint: disable=R0903
+    """Deal with obsolete arguments
+    """
+    def __call__(self, parser, namespace, values, option_string=None):
+        raise ArgumentError(self, 'obsolete argument')
+
+class FileAction(Action):
+    """Generic class to inherit from for ARgPArse action on file/dir
+    """
+    # pylint: disable=R0903
+    def __call__(self, parser, namespace, values, option_string=None):
+        self._file = normalize_path(values)
+        self._dir = dirname(self._file)
+        self.parser = parser
+        self.checks()
+        setattr(namespace, self.dest, self._file)
+
+    def checks(self):
+        """control method
+        """
+        pass
+
+class Wfile(FileAction):
+    # pylint: disable=R0903
+    """Is file writable
+    """
+    def checks(self):
+        if not exists(self._dir):
+            #raise ArgumentError(self, '"{0}" does not exist'.format(self._dir))
+            self.parser.error('file does not exist: {0}'.format(self._dir))
+        if not exists(self._file):
+            # Is parent directory writable then
+            if not access(self._dir, W_OK):
+                self.parser.error('no write access to "{0}"'.format(self._dir))
+        else:
+            if not access(self._file, W_OK):
+                self.parser.error('no write access to "{0}"'.format(self._file))
+
+class Rfile(FileAction):
+    # pylint: disable=R0903
+    """Is file readable
+    """
+    def checks(self):
+        if not exists(self._file):
+            self.parser.error('file does not exist: {0}'.format(self._file))
+        if not isfile(self._file):
+            self.parser.error('not a file: {0}'.format(self._file))
+        if not access(self._file, R_OK):
+            self.parser.error('no read access to "{0}"'.format(self._file))
+
+class Wdir(FileAction):
+    # pylint: disable=R0903
+    """Is directory writable
+    """
+    def checks(self):
+        if not exists(self._file):
+            self.parser.error('directory does not exist: {0}'.format(self._file))
+        if not isdir(self._file):
+            self.parser.error('not a directory: {0}'.format(self._file))
+        if not access(self._file, W_OK):
+            self.parser.error('no write access to "{0}"'.format(self._file))
+
+
+# VIM MODLINE
+# vim: ai ts=4 sw=4 sts=4 expandtab