Replace .config rather than reconstructing plugins, drivers, and UIs.
[hooke.git] / hooke / config.py
index 75ea680cce416c8b412941fa83bb15369ef00993..5948974f101c4ef647612f121fb3c6c69cd34af6 100644 (file)
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
+# Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
+#
+# This file is part of Hooke.
+#
+# Hooke is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# Hooke 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 Lesser General
+# Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with Hooke.  If not, see
+# <http://www.gnu.org/licenses/>.
 
-'''
-libhooke.py
+"""Configuration defaults, read/write, and template file creation for
+Hooke.
+"""
 
-General library of internal objects and utilities for Hooke.
+import ConfigParser as configparser
+import logging
+import os.path
+import textwrap
+import unittest
 
-Copyright (C) 2006 Massimo Sandal (University of Bologna, Italy).
-With algorithms contributed by Francesco Musiani (University of Bologna, Italy)
+from .compat.odict import odict as OrderedDict
+from .util.convert import to_string, from_string
 
-This program is released under the GNU General Public License version 2.
-'''
 
-import scipy
-import numpy
-import xml.dom.minidom
-import os
-import os.path
-import string
-
-#import ConfigParser
-
-def config_file_path(filename, config_dir=None):
-    if config_dir == None:
-        config_dir = os.path.abspath(
-            os.path.join(os.path.dirname(os.path.dirname(__file__)), 'conf'))
-    return os.path.join(config_dir, filename)
-
-class HookeConfig(object):
-    '''
-    Handling of Hooke configuration file
-
-    Mostly based on the simple-yet-useful examples of the Python Library Reference
-    about xml.dom.minidom
-
-    FIXME: starting to look a mess, should require refactoring
-    '''
-
-    def __init__(self, config_dir=None):
-        self.config={}
-        self.config['install']={}
-        self.config['plugins']=[]
-        self.config['drivers']=[]
-        self.config['plotmanips']=[]
-        self.config_dir = config_dir
-
-    def load_config(self, filename):
-        myconfig=file(config_file_path(filename, config_dir=self.config_dir))
-
-        #the following 3 lines are needed to strip newlines. otherwise, since newlines
-        #are XML elements too, the parser would read them (and re-save them, multiplying
-        #newlines...)
-        #yes, I'm an XML n00b
-        the_file=myconfig.read()
-        the_file_lines=the_file.split('\n')
-        the_file=''.join(the_file_lines)
-
-        self.config_tree=xml.dom.minidom.parseString(the_file)
-
-        def getText(nodelist):
-            #take the text from a nodelist
-            #from Python Library Reference 13.7.2
-            rc = ''
-            for node in nodelist:
-                if node.nodeType == node.TEXT_NODE:
-                    rc += node.data
-            return rc
-
-        def handleConfig(config):
-            install_elements=config.getElementsByTagName("install")
-            display_elements=config.getElementsByTagName("display")
-            plugins_elements=config.getElementsByTagName("plugins")
-            drivers_elements=config.getElementsByTagName("drivers")
-            defaultlist_elements=config.getElementsByTagName("defaultlist")
-            plotmanip_elements=config.getElementsByTagName("plotmanips")
-            handleInstall(install_elements)
-            handleDisplay(display_elements)
-            handlePlugins(plugins_elements)
-            handleDrivers(drivers_elements)
-            handleDefaultlist(defaultlist_elements)
-            handlePlotmanip(plotmanip_elements)
-
-        def handleInstall(install_elements):
-            for install in install_elements:
-                for node in install.childNodes:
-                    if node.nodeType == node.TEXT_NODE:
-                        continue
-                    path = os.path.abspath(getText(node.childNodes).strip())
-                    self.config['install'][str(node.tagName)] = path
-
-        def handleDisplay(display_elements):
-            for element in display_elements:
-                for attribute in element.attributes.keys():
-                    self.config[attribute]=element.getAttribute(attribute)
-
-        def handlePlugins(plugins):
-            for plugin in plugins[0].childNodes:
-                try:
-                    self.config['plugins'].append(str(plugin.tagName))
-                except: #if we allow fancy formatting of xml, there is a text node, so tagName fails for it...
-                    pass
-        #FIXME: code duplication
-        def handleDrivers(drivers):
-            for driver in drivers[0].childNodes:
-                try:
-                    self.config['drivers'].append(str(driver.tagName))
-                except: #if we allow fancy formatting of xml, there is a text node, so tagName fails for it...
-                    pass
-
-        def handlePlotmanip(plotmanips):
-            for plotmanip in plotmanips[0].childNodes:
-                try:
-                    self.config['plotmanips'].append(str(plotmanip.tagName))
-                except: #if we allow fancy formatting of xml, there is a text node, so tagName fails for it...
-                    pass
-
-        def handleDefaultlist(defaultlist):
-            '''
-            default playlist
-            '''
-            dflist=getText(defaultlist[0].childNodes)
-            self.config['defaultlist']=dflist.strip()
-
-        handleConfig(self.config_tree)
-        #making items in the dictionary more machine-readable
-        for item in self.config.keys():
+DEFAULT_PATHS = [
+    '/usr/share/hooke/hooke.cfg',
+    '/etc/hooke/hooke.cfg',
+    '~/.hooke.cfg',
+    '.hooke.cfg',
+    ]
+"""We start with the system files, and work our way to the more
+specific user files, so the user can override the sysadmin who
+in turn overrides the developer defaults.
+"""
+
+class Setting (object):
+    """An entry (section or option) in HookeConfigParser.
+    """
+    def __init__(self, section, option=None, value=None, type='string',
+                 count=1, help=None, wrap=True):
+        self.section = section
+        self.option = option
+        self.value = value
+        self.type = type
+        self.count = count
+        self.help = help
+        self.wrap = wrap
+
+    def __eq__(self, other):
+        for attr in ['__class__', 'section', 'option', 'value', 'help']:
+            value = getattr(self, attr)
+            o_value = getattr(other, attr)
+            if o_value != value:
+                return False
+        return True
+
+    def is_section(self):
+        return self.option == None
+
+    def is_option(self):
+        return not self.is_section()
+
+    def write(self, fp, value=None, comments=True, wrapper=None):
+        if comments == True and self.help != None:
+            text = self.help
+            if self.wrap == True:
+                if wrapper == None:
+                    wrapper = textwrap.TextWrapper(
+                        initial_indent='# ',
+                        subsequent_indent='# ')
+                text = wrapper.fill(text)
+            else:
+                text = '# ' + '\n# '.join(text.splitlines())
+            fp.write(text.rstrip()+'\n')
+        if self.is_section():
+            fp.write("[%s]\n" % self.section)
+        else:
+            fp.write("%s = %s\n" % (self.option,
+                                    str(value).replace('\n', '\n\t')))
+
+DEFAULT_SETTINGS = [
+    Setting('conditions', help='Default environmental conditions in case they are not specified in the force curve data.  Configuration options in this section are available to every plugin.'),
+    Setting('conditions', 'temperature', value='301', type='float', help='Temperature in Kelvin'),
+    # Logging settings
+    Setting('loggers', help='Configure loggers, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
+    Setting('loggers', 'keys', 'root, hooke', help='Hooke only uses the hooke logger, but other included modules may also use logging and you can configure their loggers here as well.'),
+    Setting('handlers', help='Configure log handlers, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
+    Setting('handlers', 'keys', 'hand1'),
+    Setting('formatters', help='Configure log formatters, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
+    Setting('formatters', 'keys', 'form1'),
+    Setting('logger_root', help='Configure the root logger, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
+    Setting('logger_root', 'level', 'NOTSET'),
+    Setting('logger_root', 'handlers', 'hand1'),
+    Setting('logger_hooke', help='Configure the hooke logger, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
+    Setting('logger_hooke', 'level', 'DEBUG'),
+    Setting('logger_hooke', 'handlers', 'hand1', help='No specific handlers here, just propagate up to the root logger'),
+    Setting('logger_hooke', 'propagate', '0'),
+    Setting('logger_hooke', 'qualname', 'hooke'),
+    Setting('handler_hand1', help='Configure the default log handler, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
+    Setting('handler_hand1', 'class', 'StreamHandler'),
+    Setting('handler_hand1', 'level', 'WARN'),
+    Setting('handler_hand1', 'formatter', 'form1'),
+    Setting('handler_hand1', 'args', '(sys.stderr,)'),
+    Setting('formatter_form1', help='Configure the default log formatter, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
+    Setting('formatter_form1', 'format', '%(asctime)s %(levelname)s %(message)s'),
+    Setting('formatter_form1', 'datefmt', '', help='Leave blank for ISO8601, e.g. "2003-01-23 00:29:50,411".'),
+    Setting('formatter_form1', 'class', 'logging.Formatter'),
+    ]
+
+def get_setting(settings, match):
+    """Return the first Setting object matching both match.section and
+    match.option.
+    """
+    for s in settings:
+        if s.section == match.section and s.option == match.option:
+            return s
+    return None
+
+class HookeConfigParser (configparser.RawConfigParser):
+    """A wrapper around configparser.RawConfigParser.
+
+    You will probably only need .read and .write.
+
+    Examples
+    --------
+
+    >>> import pprint
+    >>> import sys
+    >>> c = HookeConfigParser(default_settings=DEFAULT_SETTINGS)
+    >>> c.write(sys.stdout) # doctest: +ELLIPSIS
+    # Default environmental conditions in case they are not specified in
+    # the force curve data.
+    [conditions]
+    # Temperature in Kelvin
+    temperature = 301
+    <BLANKLINE>
+    # Configure loggers, see
+    # http://docs.python.org/library/logging.html#configuration-file-format
+    [loggers]
+    # Hooke only uses the hooke logger, but other included modules may
+    # also use logging and you can configure their loggers here as well.
+    keys = root, hooke
+    ...
+
+    class:`HookeConfigParser` automatically converts typed settings.
+
+    >>> section = 'test conversion'
+    >>> c = HookeConfigParser(default_settings=[
+    ...         Setting(section),
+    ...         Setting(section, option='my string', value='Lorem ipsum', type='string'),
+    ...         Setting(section, option='my bool', value=True, type='bool'),
+    ...         Setting(section, option='my int', value=13, type='int'),
+    ...         Setting(section, option='my float', value=3.14159, type='float'),
+    ...         ])
+    >>> pprint.pprint(c.items(section))  # doctest: +ELLIPSIS
+    [('my string', 'Lorem ipsum'),
+     ('my bool', True),
+     ('my int', 13),
+     ('my float', 3.1415...)]
+
+    However, the regular `.get()` is not typed.  Users are encouraged
+    to use the standard `.get*()` methods.
+
+    >>> c.get('test conversion', 'my bool')
+    'True'
+    >>> c.getboolean('test conversion', 'my bool')
+    True
+    """
+    def __init__(self, paths=None, default_settings=None, defaults=None,
+                 dict_type=OrderedDict, indent='# ', **kwargs):
+        # Can't use super() because RawConfigParser is a classic class
+        #super(HookeConfigParser, self).__init__(defaults, dict_type)
+        configparser.RawConfigParser.__init__(self, defaults, dict_type)
+        if paths == None:
+            paths = []
+        self._config_paths = paths
+        if default_settings == None:
+            default_settings = []
+        self._default_settings = default_settings
+        self._default_settings_dict = {}
+        for key in ['initial_indent', 'subsequent_indent']:
+            if key not in kwargs:
+                kwargs[key] = indent
+        self._comment_textwrap = textwrap.TextWrapper(**kwargs)
+        self._setup_default_settings()
+
+    def _setup_default_settings(self):
+        for setting in self._default_settings: #reversed(self._default_settings):
+            # reversed cause: http://docs.python.org/library/configparser.html
+            # "When adding sections or items, add them in the reverse order of
+            # how you want them to be displayed in the actual file."
+            self._default_settings_dict[
+                (setting.section, setting.option)] = setting
+            if setting.section not in self.sections():
+                self.add_section(setting.section)
+            if setting.option != None:
+                self.set(setting.section, setting.option, setting.value)
+
+    def read(self, filenames=None):
+        """Read and parse a filename or a list of filenames.
+
+        If filenames is None, it defaults to ._config_paths.  If a
+        filename is not in ._config_paths, it gets appended to the
+        list.  We also run os.path.expanduser() on the input filenames
+        internally so you don't have to worry about it.
+
+        Files that cannot be opened are silently ignored; this is
+        designed so that you can specify a list of potential
+        configuration file locations (e.g. current directory, user's
+        home directory, systemwide directory), and all existing
+        configuration files in the list will be read.  A single
+        filename may also be given.
+
+        Return list of successfully read files.
+        """
+        if filenames == None:
+            filenames = [os.path.expanduser(p) for p in self._config_paths]
+        else:
+            if isinstance(filenames, basestring):
+                filenames = [filenames]
+            paths = [os.path.abspath(os.path.expanduser(p))
+                     for p in self._config_paths]
+            for filename in filenames:
+                if os.path.abspath(os.path.expanduser(filename)) not in paths:
+                    self._config_paths.append(filename)
+        # Can't use super() because RawConfigParser is a classic class
+        #return super(HookeConfigParser, self).read(filenames)
+        return configparser.RawConfigParser.read(self, filenames)
+
+    def _write_setting(self, fp, section=None, option=None, value=None,
+                       **kwargs):
+        """Print, if known, a nicely wrapped comment form of a
+        setting's .help.
+        """
+        match = get_setting(self._default_settings, Setting(section, option))
+        if match == None:
+            match = Setting(section, option, value, **kwargs)
+        match.write(fp, value=value, wrapper=self._comment_textwrap)
+
+    def write(self, fp=None, comments=True):
+        """Write an .ini-format representation of the configuration state.
+
+        This expands on RawConfigParser.write() by optionally adding
+        comments when they are known (i.e. for ._default_settings).
+        However, comments are not read in during a read, so .read(x)
+        .write(x) may `not preserve comments`_.
+
+        .. _not preserve comments: http://bugs.python.org/issue1410680
+
+        Examples
+        --------
+
+        >>> import sys, StringIO
+        >>> c = HookeConfigParser()
+        >>> instring = '''
+        ... # Some comment
+        ... [section]
+        ... option = value
+        ...
+        ... '''
+        >>> c._read(StringIO.StringIO(instring), 'example.cfg')
+        >>> c.write(sys.stdout)
+        [section]
+        option = value
+        <BLANKLINE>
+        """
+        local_fp = fp == None
+        if local_fp:
+            fp = open(os.path.expanduser(self._config_paths[-1]), 'w')
+        if self._defaults:
+            self._write_setting(fp, configparser.DEFAULTSECT,
+                                help="Miscellaneous options")
+            for (key, value) in self._defaults.items():
+                self._write_setting(fp, configparser.DEFAULTSECT, key, value)
+            fp.write("\n")
+        for section in self._sections:
+            self._write_setting(fp, section)
+            for (key, value) in self._sections[section].items():
+                if key != "__name__":
+                    self._write_setting(fp, section, key, value)
+            fp.write("\n")
+        if local_fp:
+            fp.close()
+
+    def items(self, section, *args, **kwargs):
+        """Return a list of tuples with (name, value) for each option
+        in the section.
+        """
+        # Can't use super() because RawConfigParser is a classic class
+        #return super(HookeConfigParser, self).items(section, *args, **kwargs)
+        items = configparser.RawConfigParser.items(
+            self, section, *args, **kwargs)
+        for i,kv in enumerate(items):
+            key,value = kv
+            log = logging.getLogger('hooke') 
             try:
-                self.config[item]=float(self.config[item])
-            except TypeError: #we are dealing with a list, probably. keep it this way.
-                try:
-                    self.config[item]=eval(self.config[item])
-                except: #not a list, not a tuple, probably a string?
-                    pass
-            except ValueError: #if we can't get it to a number, it must be None or a string
-                if string.lower(self.config[item])=='none':
-                    self.config[item]=None
-                else:
-                    pass
-
-        return self.config
-
-
-    def save_config(self, config_filename):
-        print 'Not Implemented.'
-        pass
-
-
-def debug():
-    '''
-    debug stuff from latest rewrite of hooke_playlist.py
-    should be removed sooner or later (or substituted with new debug code!)
-    '''
-    confo=HookeConfig()
-    print confo.load_config('hooke.conf')
+                setting = self._default_settings_dict[(section, key)]
+            except KeyError, e:
+                log.error('unknown setting %s/%s: %s' % (section, key, e))
+                raise
+            try:
+                items[i] = (key, from_string(value=value, type=setting.type,
+                                             count=setting.count))
+            except ValueError, e:
+                log.error("could not convert '%s' (%s) for %s/%s: %s"
+                          % (value, type(value), section, key, e))
+                raise
+        return items
+
+    def set(self, section, option, value):
+        """Set an option."""
+        setting = self._default_settings_dict[(section, option)]
+        value = to_string(value=value, type=setting.type, count=setting.count)
+        # Can't use super() because RawConfigParser is a classic class
+        #return super(HookeConfigParser, self).set(section, option, value)
+        configparser.RawConfigParser.set(self, section, option, value)
+
+    def optionxform(self, option):
+        """
+
+        Overrides lowercasing behaviour of
+        :meth:`ConfigParser.RawConfigParser.optionxform`.
+        """
+        return option
+
+
+class TestHookeConfigParser (unittest.TestCase):
+    def test_queue_safe(self):
+        """Ensure :class:`HookeConfigParser` is Queue-safe.
+        """
+        from multiprocessing import Queue
+        q = Queue()
+        a = HookeConfigParser(default_settings=DEFAULT_SETTINGS)
+        q.put(a)
+        b = q.get(a)
+        for attr in ['_dict', '_defaults', '_sections', '_config_paths',
+                     '_default_settings']:
+            a_value = getattr(a, attr)
+            b_value = getattr(b, attr)
+            self.failUnless(
+                a_value == b_value,
+                'HookeConfigParser.%s did not survive Queue: %s != %s'
+                % (attr, a_value, b_value))