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