Fix item_name -> attribute_name KeyError in select_config().
[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                       attribute_name='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         `attribute_value` defaults to `name`, because I expect that to
358         be the most common case.
359         """
360         setting_value = self[setting_name]
361         if not setting_value:
362             raise KeyError(setting_value)
363         for item in setting_value:
364             if item[attribute_name] == attribute_value:
365                 return item
366         raise KeyError(attribute_value)