Replace .config rather than reconstructing plugins, drivers, and UIs.
[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.
141     [conditions]
142     # Temperature in Kelvin
143     temperature = 301
144     <BLANKLINE>
145     # Configure loggers, see
146     # http://docs.python.org/library/logging.html#configuration-file-format
147     [loggers]
148     # Hooke only uses the hooke logger, but other included modules may
149     # also use logging and you can configure their loggers here as well.
150     keys = root, hooke
151     ...
152
153     class:`HookeConfigParser` automatically converts typed settings.
154
155     >>> section = 'test conversion'
156     >>> c = HookeConfigParser(default_settings=[
157     ...         Setting(section),
158     ...         Setting(section, option='my string', value='Lorem ipsum', type='string'),
159     ...         Setting(section, option='my bool', value=True, type='bool'),
160     ...         Setting(section, option='my int', value=13, type='int'),
161     ...         Setting(section, option='my float', value=3.14159, type='float'),
162     ...         ])
163     >>> pprint.pprint(c.items(section))  # doctest: +ELLIPSIS
164     [('my string', 'Lorem ipsum'),
165      ('my bool', True),
166      ('my int', 13),
167      ('my float', 3.1415...)]
168
169     However, the regular `.get()` is not typed.  Users are encouraged
170     to use the standard `.get*()` methods.
171
172     >>> c.get('test conversion', 'my bool')
173     'True'
174     >>> c.getboolean('test conversion', 'my bool')
175     True
176     """
177     def __init__(self, paths=None, default_settings=None, defaults=None,
178                  dict_type=OrderedDict, indent='# ', **kwargs):
179         # Can't use super() because RawConfigParser is a classic class
180         #super(HookeConfigParser, self).__init__(defaults, dict_type)
181         configparser.RawConfigParser.__init__(self, defaults, dict_type)
182         if paths == None:
183             paths = []
184         self._config_paths = paths
185         if default_settings == None:
186             default_settings = []
187         self._default_settings = default_settings
188         self._default_settings_dict = {}
189         for key in ['initial_indent', 'subsequent_indent']:
190             if key not in kwargs:
191                 kwargs[key] = indent
192         self._comment_textwrap = textwrap.TextWrapper(**kwargs)
193         self._setup_default_settings()
194
195     def _setup_default_settings(self):
196         for setting in self._default_settings: #reversed(self._default_settings):
197             # reversed cause: http://docs.python.org/library/configparser.html
198             # "When adding sections or items, add them in the reverse order of
199             # how you want them to be displayed in the actual file."
200             self._default_settings_dict[
201                 (setting.section, setting.option)] = setting
202             if setting.section not in self.sections():
203                 self.add_section(setting.section)
204             if setting.option != None:
205                 self.set(setting.section, setting.option, setting.value)
206
207     def read(self, filenames=None):
208         """Read and parse a filename or a list of filenames.
209
210         If filenames is None, it defaults to ._config_paths.  If a
211         filename is not in ._config_paths, it gets appended to the
212         list.  We also run os.path.expanduser() on the input filenames
213         internally so you don't have to worry about it.
214
215         Files that cannot be opened are silently ignored; this is
216         designed so that you can specify a list of potential
217         configuration file locations (e.g. current directory, user's
218         home directory, systemwide directory), and all existing
219         configuration files in the list will be read.  A single
220         filename may also be given.
221
222         Return list of successfully read files.
223         """
224         if filenames == None:
225             filenames = [os.path.expanduser(p) for p in self._config_paths]
226         else:
227             if isinstance(filenames, basestring):
228                 filenames = [filenames]
229             paths = [os.path.abspath(os.path.expanduser(p))
230                      for p in self._config_paths]
231             for filename in filenames:
232                 if os.path.abspath(os.path.expanduser(filename)) not in paths:
233                     self._config_paths.append(filename)
234         # Can't use super() because RawConfigParser is a classic class
235         #return super(HookeConfigParser, self).read(filenames)
236         return configparser.RawConfigParser.read(self, filenames)
237
238     def _write_setting(self, fp, section=None, option=None, value=None,
239                        **kwargs):
240         """Print, if known, a nicely wrapped comment form of a
241         setting's .help.
242         """
243         match = get_setting(self._default_settings, Setting(section, option))
244         if match == None:
245             match = Setting(section, option, value, **kwargs)
246         match.write(fp, value=value, wrapper=self._comment_textwrap)
247
248     def write(self, fp=None, comments=True):
249         """Write an .ini-format representation of the configuration state.
250
251         This expands on RawConfigParser.write() by optionally adding
252         comments when they are known (i.e. for ._default_settings).
253         However, comments are not read in during a read, so .read(x)
254         .write(x) may `not preserve comments`_.
255
256         .. _not preserve comments: http://bugs.python.org/issue1410680
257
258         Examples
259         --------
260
261         >>> import sys, StringIO
262         >>> c = HookeConfigParser()
263         >>> instring = '''
264         ... # Some comment
265         ... [section]
266         ... option = value
267         ...
268         ... '''
269         >>> c._read(StringIO.StringIO(instring), 'example.cfg')
270         >>> c.write(sys.stdout)
271         [section]
272         option = value
273         <BLANKLINE>
274         """
275         local_fp = fp == None
276         if local_fp:
277             fp = open(os.path.expanduser(self._config_paths[-1]), 'w')
278         if self._defaults:
279             self._write_setting(fp, configparser.DEFAULTSECT,
280                                 help="Miscellaneous options")
281             for (key, value) in self._defaults.items():
282                 self._write_setting(fp, configparser.DEFAULTSECT, key, value)
283             fp.write("\n")
284         for section in self._sections:
285             self._write_setting(fp, section)
286             for (key, value) in self._sections[section].items():
287                 if key != "__name__":
288                     self._write_setting(fp, section, key, value)
289             fp.write("\n")
290         if local_fp:
291             fp.close()
292
293     def items(self, section, *args, **kwargs):
294         """Return a list of tuples with (name, value) for each option
295         in the section.
296         """
297         # Can't use super() because RawConfigParser is a classic class
298         #return super(HookeConfigParser, self).items(section, *args, **kwargs)
299         items = configparser.RawConfigParser.items(
300             self, section, *args, **kwargs)
301         for i,kv in enumerate(items):
302             key,value = kv
303             log = logging.getLogger('hooke') 
304             try:
305                 setting = self._default_settings_dict[(section, key)]
306             except KeyError, e:
307                 log.error('unknown setting %s/%s: %s' % (section, key, e))
308                 raise
309             try:
310                 items[i] = (key, from_string(value=value, type=setting.type,
311                                              count=setting.count))
312             except ValueError, e:
313                 log.error("could not convert '%s' (%s) for %s/%s: %s"
314                           % (value, type(value), section, key, e))
315                 raise
316         return items
317
318     def set(self, section, option, value):
319         """Set an option."""
320         setting = self._default_settings_dict[(section, option)]
321         value = to_string(value=value, type=setting.type, count=setting.count)
322         # Can't use super() because RawConfigParser is a classic class
323         #return super(HookeConfigParser, self).set(section, option, value)
324         configparser.RawConfigParser.set(self, section, option, value)
325
326     def optionxform(self, option):
327         """
328
329         Overrides lowercasing behaviour of
330         :meth:`ConfigParser.RawConfigParser.optionxform`.
331         """
332         return option
333
334
335 class TestHookeConfigParser (unittest.TestCase):
336     def test_queue_safe(self):
337         """Ensure :class:`HookeConfigParser` is Queue-safe.
338         """
339         from multiprocessing import Queue
340         q = Queue()
341         a = HookeConfigParser(default_settings=DEFAULT_SETTINGS)
342         q.put(a)
343         b = q.get(a)
344         for attr in ['_dict', '_defaults', '_sections', '_config_paths',
345                      '_default_settings']:
346             a_value = getattr(a, attr)
347             b_value = getattr(b, attr)
348             self.failUnless(
349                 a_value == b_value,
350                 'HookeConfigParser.%s did not survive Queue: %s != %s'
351                 % (attr, a_value, b_value))