From: kaliko Date: Sun, 22 Sep 2013 11:36:48 +0000 (+0200) Subject: Add conf management and cli parsing X-Git-Tag: mpd-sima/0.12.0pr2~46 X-Git-Url: https://git.kaliko.me/?a=commitdiff_plain;h=204fdd2bea753b024c7253a5830325b81aebbd61;p=mpd-sima.git Add conf management and cli parsing --- diff --git a/README b/README new file mode 100644 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 d6544b7..f3603cb 100755 --- 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 diff --git a/sima/core.py b/sima/core.py index f07c3e9..c751e5b 100644 --- a/sima/core.py +++ b/sima/core.py @@ -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 index 0000000..e69de29 diff --git a/sima/utils/config.py b/sima/utils/config.py new file mode 100644 index 0000000..84367d6 --- /dev/null +++ b/sima/utils/config.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2009, 2010, 2011, 2013 Jack Kaliko +# +# 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 . +# +# + +""" +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 index 0000000..988e67d --- /dev/null +++ b/sima/utils/startopt.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2009, 2010, 2011, 2012, 2013 Jack Kaliko +# +# 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 . +# +# + +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 index 0000000..017af9f --- /dev/null +++ b/sima/utils/utils.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010, 2011, 2013 Jack Kaliko +# +# 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 . +# +# +"""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