4aa9e91f637211863accd362e97f634aa83297d0
[h5config.git] / h5config / config.py
1 # Copyright (C) 2011 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of h5config.
4 #
5 # h5config is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by the
7 # Free Software Foundation, either version 3 of the License, or (at your
8 # option) any later version.
9 #
10 # h5config is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13 # General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with h5config.  If not, see <http://www.gnu.org/licenses/>.
17
18 """The basic h5config classes
19 """
20
21 import copy as _copy
22
23 from . import LOG as _LOG
24
25
26 class Setting (object):
27     "A named setting with arbitrary text values."
28     def  __init__(self, name, help='', default=None):
29         self.name = name
30         self._help = help
31         self.default = default
32
33     def __str__(self):
34         return '<%s %s>' % (self.__class__.__name__, self.name)
35
36     def __repr__(self):
37         return self.__str__()
38
39     def help(self):
40         ret = '%s  Default: %s.' % (
41             self._help, self.convert_to_text(self.default))
42         return ret.strip()
43
44     def convert_from_text(self, value):
45         return value or None
46
47     def convert_to_text(self, value):
48         return value or ''
49
50
51 class ChoiceSetting (Setting):
52     """A named setting with a limited number of possible values.
53
54     `choices` should be a list of `(config_file_value, Python value)`
55     pairs.  For example
56
57     >>> s = ChoiceSetting(name='bool',
58     ...                   choices=[('yes', True), ('no', False)])
59     >>> s.convert_from_text('yes')
60     True
61     >>> s.convert_to_text(True)
62     'yes'
63     >>> s.convert_to_text('invalid')
64     Traceback (most recent call last):
65       ...
66     ValueError: invalid
67     >>> s.help()
68     'Default: yes.  Choices: yes, no'
69     """
70     def __init__(self, choices=None, **kwargs):
71         if 'default' not in kwargs:
72             if None not in [keyval[1] for keyval in choices]:
73                 kwargs['default'] = choices[0][1]
74         super(ChoiceSetting, self).__init__(**kwargs)
75         if choices == None:
76             choices = []
77         self.choices = choices
78
79     def help(self):
80         ret = '%s  Choices: %s' % (
81             super(ChoiceSetting, self).help(),
82             ', '.join([key for key,value in self.choices]))
83         return ret.strip()
84
85     def convert_from_text(self, value):
86         return dict(self.choices)[value]
87
88     def convert_to_text(self, value):
89         for keyval in self.choices:
90             key,val = keyval
91             if val == value:
92                 return key
93         raise ValueError(value)
94
95
96 class BooleanSetting (ChoiceSetting):
97     """A named settubg that can be either true or false.
98
99     >>> s = BooleanSetting(name='bool')
100
101     >>> s.convert_from_text('yes')
102     True
103     >>> s.convert_to_text(True)
104     'yes'
105     >>> s.convert_to_text('invalid')
106     Traceback (most recent call last):
107       ...
108     ValueError: invalid
109     >>> s.help()
110     'Default: no.  Choices: yes, no'
111     """
112     def __init__(self, default=False, **kwargs):
113         super(BooleanSetting, self).__init__(
114             choices=[('yes', True), ('no', False)], default=default, **kwargs)
115
116
117 class NumericSetting (Setting):
118     """A named setting with numeric values.
119
120     Don't use this setting class.  Use a more specific subclass, such
121     as `IntegerSetting`.
122
123     >>> s = NumericSetting(name='float')
124     >>> s.default
125     0
126     >>> s.convert_to_text(13)
127     '13'
128     """
129     def __init__(self, default=0, **kwargs):
130         super(NumericSetting, self).__init__(default=default, **kwargs)
131
132     def convert_to_text(self, value):
133         return str(value)
134
135     def convert_from_text(self, value):
136         if value in [None, 'None']:
137             return None
138         return self._convert_from_text(value)
139
140     def _convert_from_text(self, value):
141         raise NotImplementedError()
142
143
144 class IntegerSetting (NumericSetting):
145     """A named setting with integer values.
146
147     >>> s = IntegerSetting(name='int')
148     >>> s.default
149     1
150     >>> s.convert_from_text('8')
151     8
152     """
153     def __init__(self, default=1, **kwargs):
154         super(IntegerSetting, self).__init__(default=default, **kwargs)
155
156     def _convert_from_text(self, value):
157         return int(value)
158
159
160 class FloatSetting (NumericSetting):
161     """A named setting with floating point values.
162
163     >>> s = FloatSetting(name='float')
164     >>> s.default
165     1.0
166     >>> s.convert_from_text('8')
167     8.0
168     >>> try:
169     ...     s.convert_from_text('invalid')
170     ... except ValueError, e:
171     ...     print 'caught a ValueError'
172     caught a ValueError
173     """
174     def __init__(self, default=1.0, **kwargs):
175         super(FloatSetting, self).__init__(default=default, **kwargs)
176
177     def _convert_from_text(self, value):
178         return float(value)
179
180
181 class FloatListSetting (Setting):
182     """A named setting with a list of floating point values.
183
184     >>> s = FloatListSetting(name='floatlist')
185     >>> s.default
186     []
187     >>> s.convert_to_text([1, 2.3])
188     '1, 2.3'
189     >>> s.convert_from_text('4.5, -6.7')  # doctest: +ELLIPSIS
190     [4.5, -6.7...]
191     >>> s.convert_to_text([])
192     ''
193     >>> s.convert_from_text('')
194     []
195     """
196     def __init__(self, default=[], **kwargs):
197         super(FloatListSetting, self).__init__(default=default, **kwargs)
198
199     def _convert_from_text(self, value):
200         if value is None:
201             return value
202         return float(value)
203
204     def convert_from_text(self, value):
205         if value is None:
206             return None
207         elif value in ['', []]:
208             return []
209         return [self._convert_from_text(x) for x in value.split(',')]
210
211     def convert_to_text(self, value):
212         if value is None:
213             return None
214         return ', '.join([str(x) for x in value])
215
216
217 class ConfigSetting (Setting):
218     """A setting that holds a pointer to a child `Config` class
219
220     This allows you to nest `Config`\s, which is a useful way to
221     contain complexity.  In order to save such a config, the backend
222     must be able to handle hierarchical storage (possibly via
223     references).
224
225     For example, a configurable AFM may contain a configurable piezo
226     scanner, as well as a configurable stepper motor.
227     """
228     def __init__(self, config_class=None, **kwargs):
229         super(ConfigSetting, self).__init__(**kwargs)
230         self.config_class = config_class
231
232
233 class ConfigListSetting (ConfigSetting):
234     """A setting that holds a list of child `Config` classes
235
236     For example, a piezo scanner with several axes.
237     """
238     def __init__(self, default=[], **kwargs):
239         super(ConfigListSetting, self).__init__(**kwargs)
240
241
242 class Config (dict):
243     """A class with a list of `Setting`\s.
244
245     `Config` instances store their values just like dictionaries, but
246     the attached list of `Settings`\s allows them to intelligently
247     dump, save, and load those values.
248     """
249     settings = []
250
251     def __init__(self, storage=None):
252         super(Config, self).__init__()
253         self.clear()
254         self.set_storage(storage=storage)
255
256     def __repr__(self):
257         return '<{} {}>'.format(self.__class__.__name__, id(self))
258
259     def set_storage(self, storage):
260         self._storage = storage
261
262     def clear(self):
263         super(Config, self).clear()
264         for s in self.settings:
265             # copy to avoid ambiguity with mutable defaults
266             self[s.name] = _copy.deepcopy(s.default)
267
268     def load(self, merge=False, **kwargs):
269         self._storage.load(config=self, merge=merge, **kwargs)
270
271     def save(self, merge=False, **kwargs):
272         self._storage.save(config=self, merge=merge, **kwargs)
273
274     def dump(self, help=False, prefix=''):
275         """Return all settings and their values as a string
276
277         >>> class MyConfig (Config):
278         ...     settings = [
279         ...         ChoiceSetting(
280         ...             name='number',
281         ...             help='I have a number behind my back...',
282         ...             default=1,
283         ...             choices=[('one', 1), ('two', 2),
284         ...                 ]),
285         ...         BooleanSetting(
286         ...             name='odd',
287         ...             help='The number behind my back is odd.',
288         ...             default=True),
289         ...         IntegerSetting(
290         ...             name='guesses',
291         ...             help='Number of guesses before epic failure.',
292         ...             default=2),
293         ...         ]
294         >>> c = MyConfig()
295         >>> print c.dump()
296         number: one
297         odd: yes
298         guesses: 2
299         >>> print c.dump(help=True)  # doctest: +NORMALIZE_WHITESPACE
300         number: one (I have a number behind my back...  Default: one.
301                      Choices: one, two)
302         odd: yes    (The number behind my back is odd.  Default: yes.
303                      Choices: yes, no)
304         guesses: 2  (Number of guesses before epic failure.  Default: 2.)
305         """
306         lines = []
307         for setting in self.settings:
308             name = setting.name
309             value = self[name]
310             try:
311                 if isinstance(setting, ConfigListSetting):
312                     if value:
313                         lines.append('{}{}:'.format(prefix, name))
314                         for i,config in enumerate(value):
315                             lines.append('{}  {}:'.format(prefix, i))
316                             lines.append(
317                                 config.dump(help=help, prefix=prefix+'    '))
318                         continue
319                 elif isinstance(setting, ConfigSetting):
320                     if value is not None:
321                         lines.append('{}{}:'.format(prefix, name))
322                         lines.append(value.dump(help=help, prefix=prefix+'  '))
323                         continue
324                 value_string = setting.convert_to_text(self[name])
325                 if help:
326                     help_string = '\t({})'.format(setting.help())
327                 else:
328                     help_string = ''
329                 lines.append('{}{}: {}{}'.format(
330                         prefix, name, value_string, help_string))
331             except Exception:
332                 _LOG.error('could not dump {} ({!r})'.format(name, value))
333                 raise
334         return '\n'.join(lines)
335
336     def select_config(self, setting_name, attribute_value=None,
337                       get_attribute=lambda value : value['name']):
338         """Select a `Config` instance from `ConfigListSetting` values
339
340         If your don't want to select `ConfigListSetting` items by
341         index, you can select them by matching another attribute.  For
342         example:
343
344         >>> from .test import TestConfig
345         >>> c = TestConfig()
346         >>> c['children'] = []
347         >>> for name in ['Jack', 'Jill']:
348         ...     child = TestConfig()
349         ...     child['name'] = name
350         ...     c['children'].append(child)
351         >>> child = c.select_config('children', 'Jack')
352         >>> child  # doctest: +ELLIPSIS
353         <TestConfig ...>
354         >>> child['name']
355         'Jack'
356
357         `get_attribute` defaults to returning `value['name']`, because
358         I expect that to be the most common case, but you can use
359         another function if neccessary, for example to drill down more
360         than one level.  Here we select by grandchild name:
361
362         >>> for name in ['Jack', 'Jill']:
363         ...     grandchild = TestConfig()
364         ...     grandchild['name'] = name + ' Junior'
365         ...     child = c.select_config('children', name)
366         ...     child['children'] = [grandchild]
367         >>> get_grandchild = lambda value : value['children'][0]['name']
368         >>> get_grandchild(child)
369         'Jill Junior'
370         >>> child = c.select_config('children', 'Jack Junior',
371         ...     get_attribute=get_grandchild)
372         >>> child  # doctest: +ELLIPSIS
373         <TestConfig ...>
374         >>> child['name']
375         'Jack'
376         """
377         setting_value = self.get(setting_name, None)
378         if not setting_value:
379             raise KeyError(setting_name)
380         for item in setting_value:
381             if get_attribute(item) == attribute_value:
382                 return item
383         raise KeyError(attribute_value)