Fix nested dumping output (using prefix and nested config names).
[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 `._keys` of `Setting`\s."
244     settings = []
245
246     def __init__(self, storage=None):
247         super(Config, self).__init__()
248         self.clear()
249         self._storage = storage
250
251     def __repr__(self):
252         return '<{} {}>'.format(self.__class__.__name__, id(self))
253
254     def clear(self):
255         super(Config, self).clear()
256         for s in self.settings:
257             # copy to avoid ambiguity with mutable defaults
258             self[s.name] = _copy.deepcopy(s.default)
259
260     def load(self, merge=False, **kwargs):
261         self._storage.load(config=self, merge=merge, **kwargs)
262
263     def save(self, merge=False, **kwargs):
264         self._storage.save(config=self, merge=merge, **kwargs)
265
266     def dump(self, help=False, prefix=''):
267         """Return all settings and their values as a string
268
269         >>> class MyConfig (Config):
270         ...     settings = [
271         ...         ChoiceSetting(
272         ...             name='number',
273         ...             help='I have a number behind my back...',
274         ...             default=1,
275         ...             choices=[('one', 1), ('two', 2),
276         ...                 ]),
277         ...         BooleanSetting(
278         ...             name='odd',
279         ...             help='The number behind my back is odd.',
280         ...             default=True),
281         ...         IntegerSetting(
282         ...             name='guesses',
283         ...             help='Number of guesses before epic failure.',
284         ...             default=2),
285         ...         ]
286         >>> c = MyConfig()
287         >>> print c.dump()
288         number: one
289         odd: yes
290         guesses: 2
291         >>> print c.dump(help=True)  # doctest: +NORMALIZE_WHITESPACE
292         number: one (I have a number behind my back...  Default: one.
293                      Choices: one, two)
294         odd: yes    (The number behind my back is odd.  Default: yes.
295                      Choices: yes, no)
296         guesses: 2  (Number of guesses before epic failure.  Default: 2.)
297         """
298         lines = []
299         for setting in self.settings:
300             name = setting.name
301             value = self[name]
302             try:
303                 if isinstance(setting, ConfigListSetting):
304                     if value:
305                         lines.append('{}{}:'.format(prefix, name))
306                         for i,config in enumerate(value):
307                             lines.append('{}  {}:'.format(prefix, i))
308                             lines.append(
309                                 config.dump(help=help, prefix=prefix+'    '))
310                         continue
311                 elif isinstance(setting, ConfigSetting):
312                     if value is not None:
313                         lines.append('{}{}:'.format(prefix, name))
314                         lines.append(value.dump(help=help, prefix=prefix+'  '))
315                         continue
316                 value_string = setting.convert_to_text(self[name])
317                 if help:
318                     help_string = '\t({})'.format(setting.help())
319                 else:
320                     help_string = ''
321                 lines.append('{}{}: {}{}'.format(
322                         prefix, name, value_string, help_string))
323             except Exception:
324                 _LOG.error('could not dump {} ({!r})'.format(name, value))
325                 raise
326         return '\n'.join(lines)