Add ListSetting and IntegerListSetting types.
[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 ListSetting(Setting):
182     """A named setting with a list of string values.
183
184     >>> s = ListSetting(name='list')
185     >>> s.default
186     []
187     >>> s.convert_to_text(['abc', 'def'])
188     'abc,def'
189     >>> s.convert_from_text('uvw, xyz')
190     ['uvw', ' xyz']
191     >>> s.convert_to_text([])
192     ''
193     >>> s.convert_from_text('')
194     []
195     """
196     def __init__(self, default=None, **kwargs):
197         if default is None:
198             default = []
199         super(ListSetting, self).__init__(default=default, **kwargs)
200
201     def _convert_from_text(self, value):
202         return 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 class IntegerListSetting (ListSetting):
217     """A named setting with a list of integer point values.
218
219     >>> s = IntegerListSetting(name='integerlist')
220     >>> s.default
221     []
222     >>> s.convert_to_text([1, 3])
223     '1,3'
224     >>> s.convert_from_text('4, -6')
225     [4, -6]
226     >>> s.convert_to_text([])
227     ''
228     >>> s.convert_from_text('')
229     []
230     """
231     def _convert_from_text(self, value):
232         if value is None:
233             return value
234         return int(value)
235
236
237 class FloatListSetting (ListSetting):
238     """A named setting with a list of floating point values.
239
240     >>> s = FloatListSetting(name='floatlist')
241     >>> s.default
242     []
243     >>> s.convert_to_text([1, 2.3])
244     '1,2.3'
245     >>> s.convert_from_text('4.5, -6.7')  # doctest: +ELLIPSIS
246     [4.5, -6.7...]
247     >>> s.convert_to_text([])
248     ''
249     >>> s.convert_from_text('')
250     []
251     """
252     def _convert_from_text(self, value):
253         if value is None:
254             return value
255         return float(value)
256
257
258 class ConfigSetting (Setting):
259     """A setting that holds a pointer to a child `Config` class
260
261     This allows you to nest `Config`\s, which is a useful way to
262     contain complexity.  In order to save such a config, the backend
263     must be able to handle hierarchical storage (possibly via
264     references).
265
266     For example, a configurable AFM may contain a configurable piezo
267     scanner, as well as a configurable stepper motor.
268     """
269     def __init__(self, config_class=None, **kwargs):
270         super(ConfigSetting, self).__init__(**kwargs)
271         self.config_class = config_class
272
273
274 class ConfigListSetting (ConfigSetting):
275     """A setting that holds a list of child `Config` classes
276
277     For example, a piezo scanner with several axes.
278     """
279     def __init__(self, default=[], **kwargs):
280         super(ConfigListSetting, self).__init__(**kwargs)
281
282
283 class Config (dict):
284     """A class with a list of `Setting`\s.
285
286     `Config` instances store their values just like dictionaries, but
287     the attached list of `Settings`\s allows them to intelligently
288     dump, save, and load those values.
289     """
290     settings = []
291
292     def __init__(self, storage=None):
293         super(Config, self).__init__()
294         self.clear()
295         self.set_storage(storage=storage)
296
297     def __repr__(self):
298         return '<{} {}>'.format(self.__class__.__name__, id(self))
299
300     def set_storage(self, storage):
301         self._storage = storage
302
303     def clear(self):
304         super(Config, self).clear()
305         for s in self.settings:
306             # copy to avoid ambiguity with mutable defaults
307             self[s.name] = _copy.deepcopy(s.default)
308
309     def load(self, merge=False, **kwargs):
310         self._storage.load(config=self, merge=merge, **kwargs)
311
312     def save(self, merge=False, **kwargs):
313         self._storage.save(config=self, merge=merge, **kwargs)
314
315     def dump(self, help=False, prefix=''):
316         """Return all settings and their values as a string
317
318         >>> class MyConfig (Config):
319         ...     settings = [
320         ...         ChoiceSetting(
321         ...             name='number',
322         ...             help='I have a number behind my back...',
323         ...             default=1,
324         ...             choices=[('one', 1), ('two', 2),
325         ...                 ]),
326         ...         BooleanSetting(
327         ...             name='odd',
328         ...             help='The number behind my back is odd.',
329         ...             default=True),
330         ...         IntegerSetting(
331         ...             name='guesses',
332         ...             help='Number of guesses before epic failure.',
333         ...             default=2),
334         ...         ]
335         >>> c = MyConfig()
336         >>> print c.dump()
337         number: one
338         odd: yes
339         guesses: 2
340         >>> print c.dump(help=True)  # doctest: +NORMALIZE_WHITESPACE
341         number: one (I have a number behind my back...  Default: one.
342                      Choices: one, two)
343         odd: yes    (The number behind my back is odd.  Default: yes.
344                      Choices: yes, no)
345         guesses: 2  (Number of guesses before epic failure.  Default: 2.)
346         """
347         lines = []
348         for setting in self.settings:
349             name = setting.name
350             value = self[name]
351             try:
352                 if isinstance(setting, ConfigListSetting):
353                     if value:
354                         lines.append('{}{}:'.format(prefix, name))
355                         for i,config in enumerate(value):
356                             lines.append('{}  {}:'.format(prefix, i))
357                             lines.append(
358                                 config.dump(help=help, prefix=prefix+'    '))
359                         continue
360                 elif isinstance(setting, ConfigSetting):
361                     if value is not None:
362                         lines.append('{}{}:'.format(prefix, name))
363                         lines.append(value.dump(help=help, prefix=prefix+'  '))
364                         continue
365                 value_string = setting.convert_to_text(self[name])
366                 if help:
367                     help_string = '\t({})'.format(setting.help())
368                 else:
369                     help_string = ''
370                 lines.append('{}{}: {}{}'.format(
371                         prefix, name, value_string, help_string))
372             except Exception:
373                 _LOG.error('could not dump {} ({!r})'.format(name, value))
374                 raise
375         return '\n'.join(lines)
376
377     def select_config(self, setting_name, attribute_value=None,
378                       get_attribute=lambda value : value['name']):
379         """Select a `Config` instance from `ConfigListSetting` values
380
381         If your don't want to select `ConfigListSetting` items by
382         index, you can select them by matching another attribute.  For
383         example:
384
385         >>> from .test import TestConfig
386         >>> c = TestConfig()
387         >>> c['children'] = []
388         >>> for name in ['Jack', 'Jill']:
389         ...     child = TestConfig()
390         ...     child['name'] = name
391         ...     c['children'].append(child)
392         >>> child = c.select_config('children', 'Jack')
393         >>> child  # doctest: +ELLIPSIS
394         <TestConfig ...>
395         >>> child['name']
396         'Jack'
397
398         `get_attribute` defaults to returning `value['name']`, because
399         I expect that to be the most common case, but you can use
400         another function if neccessary, for example to drill down more
401         than one level.  Here we select by grandchild name:
402
403         >>> for name in ['Jack', 'Jill']:
404         ...     grandchild = TestConfig()
405         ...     grandchild['name'] = name + ' Junior'
406         ...     child = c.select_config('children', name)
407         ...     child['children'] = [grandchild]
408         >>> get_grandchild = lambda value : value['children'][0]['name']
409         >>> get_grandchild(child)
410         'Jill Junior'
411         >>> child = c.select_config('children', 'Jack Junior',
412         ...     get_attribute=get_grandchild)
413         >>> child  # doctest: +ELLIPSIS
414         <TestConfig ...>
415         >>> child['name']
416         'Jack'
417         """
418         setting_value = self.get(setting_name, None)
419         if not setting_value:
420             raise KeyError(setting_name)
421         for item in setting_value:
422             if get_attribute(item) == attribute_value:
423                 return item
424         raise KeyError(attribute_value)