d49841d6e20bc5733d7dbd37c21352a442381a30
[hooke.git] / hooke / config.py
1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of Hooke.
4 #
5 # Hooke is free software: you can redistribute it and/or modify it
6 # under the terms of the GNU Lesser General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # Hooke is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12 # or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General
13 # Public License for more details.
14 #
15 # You should have received a copy of the GNU Lesser General Public
16 # License along with Hooke.  If not, see
17 # <http://www.gnu.org/licenses/>.
18
19 """Configuration defaults, read/write, and template file creation for
20 Hooke.
21 """
22
23 import ConfigParser as configparser
24 import logging
25 import os.path
26 import textwrap
27 import unittest
28
29 from .compat.odict import odict as OrderedDict
30 from .util.convert import to_string, from_string
31
32
33 DEFAULT_PATHS = [
34     '/usr/share/hooke/hooke.cfg',
35     '/etc/hooke/hooke.cfg',
36     '~/.hooke.cfg',
37     '.hooke.cfg',
38     ]
39 """We start with the system files, and work our way to the more
40 specific user files, so the user can override the sysadmin who
41 in turn overrides the developer defaults.
42 """
43
44 class Setting (object):
45     """An entry (section or option) in HookeConfigParser.
46     """
47     def __init__(self, section, option=None, value=None, type='string',
48                  count=1, help=None, wrap=True):
49         self.section = section
50         self.option = option
51         self.value = value
52         self.type = type
53         self.count = count
54         self.help = help
55         self.wrap = wrap
56
57     def __eq__(self, other):
58         for attr in ['__class__', 'section', 'option', 'value', 'help']:
59             value = getattr(self, attr)
60             o_value = getattr(other, attr)
61             if o_value != value:
62                 return False
63         return True
64
65     def is_section(self):
66         return self.option == None
67
68     def is_option(self):
69         return not self.is_section()
70
71     def write(self, fp, value=None, comments=True, wrapper=None):
72         if comments == True and self.help != None:
73             text = self.help
74             if self.wrap == True:
75                 if wrapper == None:
76                     wrapper = textwrap.TextWrapper(
77                         initial_indent='# ',
78                         subsequent_indent='# ')
79                 text = wrapper.fill(text)
80             else:
81                 text = '# ' + '\n# '.join(text.splitlines())
82             fp.write(text.rstrip()+'\n')
83         if self.is_section():
84             fp.write("[%s]\n" % self.section)
85         else:
86             fp.write("%s = %s\n" % (self.option,
87                                     str(value).replace('\n', '\n\t')))
88
89 DEFAULT_SETTINGS = [
90     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.'),
91     Setting('conditions', 'temperature', value='301', type='float', help='Temperature in Kelvin'),
92     # Logging settings
93     Setting('loggers', help='Configure loggers, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
94     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.'),
95     Setting('handlers', help='Configure log handlers, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
96     Setting('handlers', 'keys', 'hand1'),
97     Setting('formatters', help='Configure log formatters, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
98     Setting('formatters', 'keys', 'form1'),
99     Setting('logger_root', help='Configure the root logger, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
100     Setting('logger_root', 'level', 'NOTSET'),
101     Setting('logger_root', 'handlers', 'hand1'),
102     Setting('logger_hooke', help='Configure the hooke logger, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
103     Setting('logger_hooke', 'level', 'DEBUG'),
104     Setting('logger_hooke', 'handlers', 'hand1', help='No specific handlers here, just propagate up to the root logger'),
105     Setting('logger_hooke', 'propagate', '0'),
106     Setting('logger_hooke', 'qualname', 'hooke'),
107     Setting('handler_hand1', help='Configure the default log handler, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
108     Setting('handler_hand1', 'class', 'StreamHandler'),
109     Setting('handler_hand1', 'level', 'WARN'),
110     Setting('handler_hand1', 'formatter', 'form1'),
111     Setting('handler_hand1', 'args', '(sys.stderr,)'),
112     Setting('formatter_form1', help='Configure the default log formatter, see\nhttp://docs.python.org/library/logging.html#configuration-file-format', wrap=False),
113     Setting('formatter_form1', 'format', '%(asctime)s %(levelname)s %(message)s'),
114     Setting('formatter_form1', 'datefmt', '', help='Leave blank for ISO8601, e.g. "2003-01-23 00:29:50,411".'),
115     Setting('formatter_form1', 'class', 'logging.Formatter'),
116     ]
117
118 def get_setting(settings, match):
119     """Return the first Setting object matching both match.section and
120     match.option.
121     """
122     for s in settings:
123         if s.section == match.section and s.option == match.option:
124             return s
125     return None
126
127 class HookeConfigParser (configparser.RawConfigParser):
128     """A wrapper around configparser.RawConfigParser.
129
130     You will probably only need .read and .write.
131
132     Examples
133     --------
134
135     >>> import pprint
136     >>> import sys
137     >>> c = HookeConfigParser(default_settings=DEFAULT_SETTINGS)
138     >>> c.write(sys.stdout) # doctest: +ELLIPSIS
139     # Default environmental conditions in case they are not specified in
140     # the force curve data.  Configuration options in this section are
141     # available to every plugin.
142     [conditions]
143     # Temperature in Kelvin
144     temperature = 301
145     <BLANKLINE>
146     # Configure loggers, see
147     # http://docs.python.org/library/logging.html#configuration-file-format
148     [loggers]
149     # Hooke only uses the hooke logger, but other included modules may
150     # also use logging and you can configure their loggers here as well.
151     keys = root, hooke
152     ...
153
154     class:`HookeConfigParser` automatically converts typed settings.
155
156     >>> section = 'test conversion'
157     >>> c = HookeConfigParser(default_settings=[
158     ...         Setting(section),
159     ...         Setting(section, option='my string', value='Lorem ipsum', type='string'),
160     ...         Setting(section, option='my bool', value=True, type='bool'),
161     ...         Setting(section, option='my int', value=13, type='int'),
162     ...         Setting(section, option='my float', value=3.14159, type='float'),
163     ...         ])
164     >>> pprint.pprint(c.items(section))  # doctest: +ELLIPSIS
165     [('my string', u'Lorem ipsum'),
166      ('my bool', True),
167      ('my int', 13),
168      ('my float', 3.1415...)]
169
170     However, the regular `.get()` is not typed.  Users are encouraged
171     to use the standard `.get*()` methods.
172
173     >>> c.get('test conversion', 'my bool')
174     u'True'
175     >>> c.getboolean('test conversion', 'my bool')
176     True
177     """
178     def __init__(self, paths=None, default_settings=None, defaults=None,
179                  dict_type=OrderedDict, indent='# ', **kwargs):
180         # Can't use super() because RawConfigParser is a classic class
181         #super(HookeConfigParser, self).__init__(defaults, dict_type)
182         configparser.RawConfigParser.__init__(self, defaults, dict_type)
183         if paths == None:
184             paths = []
185         self._config_paths = paths
186         if default_settings == None:
187             default_settings = []
188         self._default_settings = default_settings
189         self._default_settings_dict = {}
190         for key in ['initial_indent', 'subsequent_indent']:
191             if key not in kwargs:
192                 kwargs[key] = indent
193         self._comment_textwrap = textwrap.TextWrapper(**kwargs)
194         self._setup_default_settings()
195
196     def _setup_default_settings(self):
197         for setting in self._default_settings: #reversed(self._default_settings):
198             # reversed cause: http://docs.python.org/library/configparser.html
199             # "When adding sections or items, add them in the reverse order of
200             # how you want them to be displayed in the actual file."
201             self._default_settings_dict[
202                 (setting.section, setting.option)] = setting
203             if setting.section not in self.sections():
204                 self.add_section(setting.section)
205             if setting.option != None:
206                 self.set(setting.section, setting.option, setting.value)
207
208     def read(self, filenames=None):
209         """Read and parse a filename or a list of filenames.
210
211         If filenames is None, it defaults to ._config_paths.  If a
212         filename is not in ._config_paths, it gets appended to the
213         list.  We also run os.path.expanduser() on the input filenames
214         internally so you don't have to worry about it.
215
216         Files that cannot be opened are silently ignored; this is
217         designed so that you can specify a list of potential
218         configuration file locations (e.g. current directory, user's
219         home directory, systemwide directory), and all existing
220         configuration files in the list will be read.  A single
221         filename may also be given.
222
223         Return list of successfully read files.
224         """
225         if filenames == None:
226             filenames = [os.path.expanduser(p) for p in self._config_paths]
227         else:
228             if isinstance(filenames, basestring):
229                 filenames = [filenames]
230             paths = [os.path.abspath(os.path.expanduser(p))
231                      for p in self._config_paths]
232             for filename in filenames:
233                 if os.path.abspath(os.path.expanduser(filename)) not in paths:
234                     self._config_paths.append(filename)
235         # Can't use super() because RawConfigParser is a classic class
236         #return super(HookeConfigParser, self).read(filenames)
237         return configparser.RawConfigParser.read(self, self._config_paths)
238
239     def _write_setting(self, fp, section=None, option=None, value=None,
240                        **kwargs):
241         """Print, if known, a nicely wrapped comment form of a
242         setting's .help.
243         """
244         match = get_setting(self._default_settings, Setting(section, option))
245         if match == None:
246             match = Setting(section, option, value, **kwargs)
247         match.write(fp, value=value, wrapper=self._comment_textwrap)
248
249     def write(self, fp=None, comments=True):
250         """Write an .ini-format representation of the configuration state.
251
252         This expands on RawConfigParser.write() by optionally adding
253         comments when they are known (i.e. for ._default_settings).
254         However, comments are not read in during a read, so .read(x)
255         .write(x) may `not preserve comments`_.
256
257         .. _not preserve comments: http://bugs.python.org/issue1410680
258
259         Examples
260         --------
261
262         >>> import sys, StringIO
263         >>> c = HookeConfigParser()
264         >>> instring = '''
265         ... # Some comment
266         ... [section]
267         ... option = value
268         ...
269         ... '''
270         >>> c._read(StringIO.StringIO(instring), 'example.cfg')
271         >>> c.write(sys.stdout)
272         [section]
273         option = value
274         <BLANKLINE>
275         """
276         local_fp = fp == None
277         if local_fp:
278             fp = open(os.path.expanduser(self._config_paths[-1]), 'w')
279         if self._defaults:
280             self._write_setting(fp, configparser.DEFAULTSECT,
281                                 help="Miscellaneous options")
282             for (key, value) in self._defaults.items():
283                 self._write_setting(fp, configparser.DEFAULTSECT, key, value)
284             fp.write("\n")
285         for section in self._sections:
286             self._write_setting(fp, section)
287             for (key, value) in self._sections[section].items():
288                 if key != "__name__":
289                     self._write_setting(fp, section, key, value)
290             fp.write("\n")
291         if local_fp:
292             fp.close()
293
294     def items(self, section, *args, **kwargs):
295         """Return a list of tuples with (name, value) for each option
296         in the section.
297         """
298         # Can't use super() because RawConfigParser is a classic class
299         #return super(HookeConfigParser, self).items(section, *args, **kwargs)
300         items = configparser.RawConfigParser.items(
301             self, section, *args, **kwargs)
302         for i,kv in enumerate(items):
303             key,value = kv
304             log = logging.getLogger('hooke') 
305             try:
306                 setting = self._default_settings_dict[(section, key)]
307             except KeyError, e:
308                 log.error('unknown setting %s/%s: %s' % (section, key, e))
309                 raise
310             try:
311                 items[i] = (key, from_string(value=value, type=setting.type,
312                                              count=setting.count))
313             except ValueError, e:
314                 log.error("could not convert '%s' (%s) for %s/%s: %s"
315                           % (value, type(value), section, key, e))
316                 raise
317         return items
318
319     def set(self, section, option, value):
320         """Set an option."""
321         setting = self._default_settings_dict[(section, option)]
322         value = to_string(value=value, type=setting.type, count=setting.count)
323         # Can't use super() because RawConfigParser is a classic class
324         #return super(HookeConfigParser, self).set(section, option, value)
325         configparser.RawConfigParser.set(self, section, option, value)
326
327     def optionxform(self, option):
328         """
329
330         Overrides lowercasing behaviour of
331         :meth:`ConfigParser.RawConfigParser.optionxform`.
332         """
333         return option
334
335
336 class TestHookeConfigParser (unittest.TestCase):
337     def test_queue_safe(self):
338         """Ensure :class:`HookeConfigParser` is Queue-safe.
339         """
340         from multiprocessing import Queue
341         q = Queue()
342         a = HookeConfigParser(default_settings=DEFAULT_SETTINGS)
343         q.put(a)
344         b = q.get(a)
345         for attr in ['_dict', '_defaults', '_sections', '_config_paths',
346                      '_default_settings']:
347             a_value = getattr(a, attr)
348             b_value = getattr(b, attr)
349             self.failUnless(
350                 a_value == b_value,
351                 'HookeConfigParser.%s did not survive Queue: %s != %s'
352                 % (attr, a_value, b_value))