dataset.value has been deprecated since h5py version 1.3.
[pypiezo.git] / pypiezo / config.py
1 """Piezo configuration
2
3 Broken out from the main modules to make it easy to override if you
4 wish to use a different configuration file format.
5
6 The HDF5- and YAML-backed config file classes are created dynamically:
7
8 >>> print '\\n'.join([obj for obj in sorted(locals().keys())
9 ...                   if obj.endswith('Config')
10 ...                   and not obj.startswith('_')])
11 HDF5_AxisConfig
12 HDF5_BaseConfig
13 HDF5_ChannelConfig
14 HDF5_InputChannelConfig
15 HDF5_OutputChannelConfig
16 YAML_AxisConfig
17 YAML_BaseConfig
18 YAML_ChannelConfig
19 YAML_InputChannelConfig
20 YAML_OutputChannelConfig
21
22 The first time you use them, the file they create will probably be
23 empty or not exist.
24
25 >>> import os
26 >>> import tempfile
27 >>> fd,filename = tempfile.mkstemp(suffix='.h5', prefix='pypiezo-')
28 >>> os.close(fd)
29
30 >>> c = HDF5_BaseConfig(filename=filename, group='/base')
31 >>> c.load()
32
33 Loading will create a stub group group if it hadn't existed before.
34
35 >>> pprint_HDF5(filename)
36 /
37   /base
38 >>> print c.dump(from_file=True)
39 <BLANKLINE>
40
41 Saving fills in all the config values.
42
43 >>> c['syslog'] = True
44 >>> c.save()
45 >>> pprint_HDF5(filename)  # doctest: +REPORT_UDIFF
46 /
47   /base
48     <HDF5 dataset "log-level": shape (), type "|S4">
49       warn
50     <HDF5 dataset "matplotlib": shape (), type "|S2">
51       no
52     <HDF5 dataset "syslog": shape (), type "|S3">
53       yes
54 >>> print c.dump(from_file=True)
55 log-level: warn
56 matplotlib: no
57 syslog: yes
58
59 If you want more details, you can dump with help strings.
60
61 >>> print c.dump(help=True, from_file=True)  # doctest: +NORMALIZE_WHITESPACE
62 log-level: warn\t(Module logging level.  Default: warn.  Choices:
63                \t critical, error, warn, info, debug)
64 matplotlib: no\t(Plot piezo motion using `matplotlib`.  Default: no.
65               \t Choices: yes, no)
66 syslog: yes\t(Log to syslog (otherwise log to stderr).  Default: no.
67            \t Choices: yes, no)
68
69 Settings also support `None`, even if they have numeric types.
70
71 >>> c = HDF5_AxisConfig(filename=filename, group='/z-axis')
72 >>> c.load()
73 >>> c.save()
74 >>> c.load()
75 >>> print c.dump(from_file=True)
76 gain: 1.0
77 maximum: None
78 minimum: None
79 sensitivity: 1.0
80 >>> print (c['minimum'] == None)
81 True
82
83 Cleanup our temporary config file.
84
85 >>> os.remove(filename)
86 """
87
88 import logging as _logging
89 import os.path as _os_path
90 import sys as _sys
91
92 import h5py as _h5py
93 import yaml as _yaml
94
95 from . import LOG as _LOG
96
97
98 class _Setting (object):
99     "A named setting with arbitrart text values."
100     def __init__(self, name, help='', default=None):
101         self.name = name
102         self._help = help
103         self.default = default
104
105     def __str__(self):
106         return '<%s %s>' % (self.__class__.__name__, self.name)
107
108     def __repr__(self):
109         return self.__str__()
110
111     def help(self):
112         ret = '%s  Default: %s.' % (
113             self._help, self.convert_to_text(self.default))
114         return ret.strip()
115
116     def convert_from_text(self, value):
117         return value
118
119     def convert_to_text(self, value):
120         return value
121
122
123 class _ChoiceSetting (_Setting):
124     """A named setting with a limited number of possible values.
125
126     `choices` should be a list of `(config_file_value, Python value)`
127     pairs.  For example
128
129     >>> s = _ChoiceSetting(name='bool',
130     ...                    choices=[('yes', True), ('no', False)])
131     >>> s.convert_from_text('yes')
132     True
133     >>> s.convert_to_text(True)
134     'yes'
135     >>> s.convert_to_text('invalid')
136     Traceback (most recent call last):
137       ...
138     ValueError: invalid
139     >>> s.help()
140     'Default: yes.  Choices: yes, no'
141     """
142     def __init__(self, choices=None, **kwargs):
143         if 'default' not in kwargs:
144             if None not in [keyval[1] for keyval in choices]:
145                 kwargs['default'] = choices[0][1]
146         super(_ChoiceSetting, self).__init__(**kwargs)
147         if choices == None:
148             choices = []
149         self.choices = choices
150
151     def help(self):
152         ret = '%s  Choices: %s' % (
153             super(_ChoiceSetting, self).help(),
154             ', '.join([key for key,value in self.choices]))
155         return ret.strip()
156
157     def convert_from_text(self, value):
158         return dict(self.choices)[value]
159
160     def convert_to_text(self, value):
161         for keyval in self.choices:
162             key,val = keyval
163             if val == value:
164                 return key
165         raise ValueError(value)
166
167
168 class _BooleanSetting (_ChoiceSetting):
169     """A named settubg that can be either true or false.
170
171     >>> s = _BooleanSetting(name='bool')
172
173     >>> s.convert_from_text('yes')
174     True
175     >>> s.convert_to_text(True)
176     'yes'
177     >>> s.convert_to_text('invalid')
178     Traceback (most recent call last):
179       ...
180     ValueError: invalid
181     >>> s.help()
182     'Default: no.  Choices: yes, no'
183     """
184     def __init__(self, **kwargs):
185         assert 'choices' not in kwargs
186         if 'default' not in kwargs:
187             kwargs['default'] = False
188         super(_BooleanSetting, self).__init__(
189             choices=[('yes', True), ('no', False)], **kwargs)
190
191
192 class _NumericSetting (_Setting):
193     """A named setting with numeric values.
194
195     >>> s = _NumericSetting(name='float')
196     >>> s.default
197     0
198     >>> s.convert_to_text(13)
199     '13'
200     """
201     _default_value = 0
202
203     def __init__(self, **kwargs):
204         if 'default' not in kwargs:
205             kwargs['default'] = self._default_value
206         super(_NumericSetting, self).__init__(**kwargs)
207
208     def convert_to_text(self, value):
209         return str(value)
210
211     def convert_from_text(self, value):
212         if value in [None, 'None']:
213             return None
214         return self._convert_from_text(value)
215
216     def _convert_from_text(self, value):
217         raise NotImplementedError()
218
219
220 class _IntegerSetting (_NumericSetting):
221     """A named setting with integer values.
222
223     >>> s = _IntegerSetting(name='int')
224     >>> s.default
225     1
226     >>> s.convert_from_text('8')
227     8
228     """
229     _default_value = 1
230
231     def _convert_from_text(self, value):
232         return int(value)
233
234
235 class _FloatSetting (_NumericSetting):
236     """A named setting with floating point values.
237
238     >>> s = _FloatSetting(name='float')
239     >>> s.default
240     1.0
241     >>> s.convert_from_text('8')
242     8.0
243     >>> s.convert_from_text('invalid')
244     Traceback (most recent call last):
245       ...
246     ValueError: invalid literal for float(): invalid
247     """
248     _default_value = 1.0
249
250     def _convert_from_text(self, value):
251         return float(value)
252
253
254 class _FloatListSetting (_Setting):
255     """A named setting with a list of floating point values.
256
257     >>> s = _FloatListSetting(name='floatlist')
258     >>> s.default
259     []
260     >>> s.convert_to_text([1, 2.3])
261     '1, 2.3'
262     >>> s.convert_from_text('4.5, -6.7')  # doctest: +ELLIPSIS
263     [4.5, -6.700...]
264     >>> s.convert_to_text([])
265     ''
266     >>> s.convert_from_text('')
267     []
268     """
269     def __init__(self, **kwargs):
270         if 'default' not in kwargs:
271             kwargs['default'] = []
272         super(_FloatListSetting, self).__init__(**kwargs)
273
274     def _convert_from_text(self, value):
275         if value is None:
276             return value
277         return float(value)
278
279     def convert_from_text(self, value):
280         if value is None:
281             return None
282         elif value == '':
283             return []
284         return [self._convert_from_text(x) for x in value.split(',')]
285
286     def convert_to_text(self, value):
287         if value is None:
288             return None
289         return ', '.join([str(x) for x in value])
290
291
292 class _Config (dict):
293     "A class with a list `._keys` of `_Setting`\s."
294     settings = []
295
296     def __init__(self):
297         for s in self.settings:
298             self[s.name] = s.default
299
300     def dump(self, help=False):
301         """Return all settings and their values as a string
302
303         >>> b = _BaseConfig()
304         >>> print b.dump()
305         syslog: no
306         matplotlib: no
307         log-level: warn
308         >>> print b.dump(help=True)  # doctest: +NORMALIZE_WHITESPACE
309         syslog: no          (Log to syslog (otherwise log to stderr).
310                              Default: no.  Choices: yes, no)
311         matplotlib: no      (Plot piezo motion using `matplotlib`.
312                              Default: no.  Choices: yes, no)
313         log-level: warn     (Module logging level.  Default: warn.
314                              Choices: critical, error, warn, info, debug)
315         """
316         lines = []
317         settings = dict([(s.name, s) for s in self.settings])
318         for key,value in self.iteritems():
319             if key in settings:
320                 setting = settings[key]
321                 value_string = setting.convert_to_text(value)
322                 if help:
323                     help_string = '\t(%s)' % setting.help()
324                 else:
325                     help_string = ''
326                 lines.append('%s: %s%s' % (key, value_string, help_string))
327         return '\n'.join(lines)
328
329
330 class _BackedConfig (_Config):
331     "A `_Config` instance with some kind of storage interface"
332     def load(self):
333         raise NotImplementedError()
334
335     def save(self):
336         raise NotImplementedError()
337
338
339 class _BaseConfig (_Config):
340     """Configure `pypiezo` module operation
341
342     >>> b = _BaseConfig()
343     >>> b.settings  # doctest: +NORMALIZE_WHITESPACE
344     [<_ChoiceSetting log-level>, <_BooleanSetting syslog>,
345      <_BooleanSetting matplotlib>]
346     >>> print b['log-level'] == _logging.WARN
347     True
348     """
349     settings = [
350         _ChoiceSetting(
351             name='log-level',
352             help='Module logging level.',
353             default=_logging.WARN,
354             choices=[
355                 ('critical', _logging.CRITICAL),
356                 ('error', _logging.ERROR),
357                 ('warn', _logging.WARN),
358                 ('info', _logging.INFO),
359                 ('debug', _logging.DEBUG),
360                 ]),
361         _BooleanSetting(
362             name='syslog',
363             help='Log to syslog (otherwise log to stderr).',
364             default=False),
365         _BooleanSetting(
366             name='matplotlib',
367             help='Plot piezo motion using `matplotlib`.',
368             default=False),
369         ]
370
371
372 class _AxisConfig (_Config):
373     "Configure a single piezo axis"
374     settings = [
375         _FloatSetting(
376             name='gain',
377             help=(
378                 'Volts applied at piezo per volt output from the DAQ card '
379                 '(e.g. if your DAQ output is amplified before driving the '
380                 'piezo),')),
381         _FloatSetting(
382             name='sensitivity',
383             help='Meters of piezo deflection per volt applied to the piezo.'),
384         _FloatSetting(
385             name='minimum',
386             help='Set a lower limit on allowed output voltage',
387             default=None),
388         _FloatSetting(
389             name='maximum',
390             help='Set an upper limit on allowed output voltage',
391             default=None),
392         ]
393
394
395 class _ChannelConfig (_Config):
396     settings = [
397         _Setting(
398             name='device',
399             help='Comedi device.',
400             default='/dev/comedi0'),
401         _IntegerSetting(
402             name='subdevice',
403             help='Comedi subdevice index.  -1 for automatic detection.',
404             default=-1),
405         _IntegerSetting(
406             name='channel',
407             help='Subdevice channel index.',
408             default=0),
409         _IntegerSetting(
410             name='maxdata',
411             help="Channel's maximum bit value."),
412         _IntegerSetting(
413             name='range',
414             help="Channel's selected range index."),
415         _FloatListSetting(
416             name='conversion-coefficients',
417             help=('Bit to physical unit conversion coefficients starting with '
418                   'the constant coefficient.')),
419         _FloatSetting(
420             name='conversion-origin',
421             help=('Origin (bit offset) of bit to physical polynomial '
422                   'expansion.')),
423         _FloatListSetting(
424             name='inverse-conversion-coefficients',
425             help=('Physical unit to bit conversion coefficients starting with '
426                   'the constant coefficient.')),
427         _FloatSetting(
428             name='inverse-conversion-origin',
429             help=('Origin (physical unit offset) of physical to bit '
430                   'polynomial expansion.')),
431         ]
432
433
434 class _OutputChannelConfig (_ChannelConfig):
435     pass
436
437
438 class _InputChannelConfig (_ChannelConfig):
439     pass
440
441
442 def pprint_HDF5(*args, **kwargs):
443     print pformat_HDF5(*args, **kwargs)
444
445 def pformat_HDF5(filename, group='/'):
446     f = _h5py.File(filename, 'r')
447     cwg = f[group]
448     return '\n'.join(_pformat_hdf5(cwg))
449
450 def _pformat_hdf5(cwg, depth=0):
451     lines = []
452     lines.append('  '*depth + cwg.name)
453     depth += 1 
454     for key,value in cwg.iteritems():
455         if isinstance(value, _h5py.Group):
456             lines.extend(_pformat_hdf5(value, depth))
457         elif isinstance(value, _h5py.Dataset):
458             lines.append('  '*depth + str(value))
459             lines.append('  '*(depth+1) + str(value[...]))
460         else:
461             lines.append('  '*depth + str(value))
462     return lines
463                          
464
465 class _HDF5Config (_BackedConfig):
466     """Mixin to back a `_Config` class with an HDF5 file.
467
468     TODO: Special handling for Choice (enums), FloatList (arrays), etc.?
469
470     The `.save` and `.load` methods have an optional `group` argument
471     that allows you to save and load settings from an externally
472     opened HDF5 file.  This can make it easier to stash several
473     related `_Config` classes in a single file.  For example
474
475     >>> import os
476     >>> import tempfile
477     >>> fd,filename = tempfile.mkstemp(suffix='.h5', prefix='pypiezo-')
478     >>> os.close(fd)
479
480     >>> f = _h5py.File(filename, 'a')
481     >>> c = HDF5_BaseConfig(filename='untouched_file.h5',
482     ...                     group='/untouched/group')
483     >>> c['syslog'] = True
484     >>> group = f.create_group('base')
485     >>> c.save(group)
486     >>> pprint_HDF5(filename)
487     /
488       /base
489         <HDF5 dataset "log-level": shape (), type "|S4">
490           warn
491         <HDF5 dataset "matplotlib": shape (), type "|S2">
492           no
493         <HDF5 dataset "syslog": shape (), type "|S3">
494           yes
495     >>> d = HDF5_BaseConfig(filename='untouched_file.h5',
496     ...                     group='/untouched/group')
497     >>> d.load(group)
498     >>> d['syslog']
499     True
500
501     >>> f.close()
502     >>> os.remove(filename)
503     """
504     def __init__(self, filename, group='/', **kwargs):
505         super(_HDF5Config, self).__init__(**kwargs)
506         self.filename = filename
507         assert group.startswith('/'), group
508         if not group.endswith('/'):
509             group += '/'
510         self.group = group
511         self._file_checked = False
512
513     def _check_file(self):
514         if self._file_checked:
515             return
516         self._setup_file()
517         self._file_checked = True
518
519     def _setup_file(self):
520         f = _h5py.File(self.filename, 'a')
521         cwg = f  # current working group
522
523         # Create the group where the settings are stored (if necessary)
524         gpath = ['']
525         for group in self.group.strip('/').split('/'):
526             gpath.append(group)
527             if group not in cwg.keys():
528                 _LOG.debug('creating group %s in %s'
529                            % ('/'.join(gpath), self.filename))
530                 cwg.create_group(group)
531             cwg = cwg[group]
532         f.close()
533
534     def dump(self, help=False, from_file=False):
535         """Return the relevant group in `self.filename` as a string
536
537         Extends the base :meth:`dump` by adding the `from_file`
538         option.  If `from_file` is true, dump all entries that
539         currently exist in the relevant group, rather than listing all
540         settings defined in the instance dictionary.
541         """
542         if from_file:
543             self._check_file()
544             f = _h5py.File(self.filename, 'r')
545             cwg = f[self.group]
546             lines = []
547             settings = dict([(s.name, s) for s in self.settings])
548             for key,value in cwg.iteritems():
549                 if help and key in settings:
550                     help_string = '\t(%s)' % settings[key].help()
551                 else:
552                     help_string = ''
553                 lines.append('%s: %s%s' % (key, value[...], help_string))
554             return '\n'.join(lines)
555         return super(_HDF5Config, self).dump(help=help)
556
557     def load(self, group=None):
558         if group is None:
559             self._check_file()
560             f = _h5py.File(self.filename, 'r')
561             group = f[self.group]
562         else:
563             f = None
564         for s in self.settings:
565             if s.name not in group.keys():
566                 continue
567             self[s.name] = s.convert_from_text(group[s.name][...])
568         if f:
569             f.close()
570
571     def save(self, group=None):
572         if group is None:
573             self._check_file()
574             f = _h5py.File(self.filename, 'a')
575             group = f[self.group]
576         else:
577             f = None
578         for s in self.settings:
579             try:
580                 dataset = group[s.name]
581             except KeyError:
582                 group[s.name] = s.convert_to_text(self[s.name])
583             else:
584                 group[s.name][...] = s.convert_to_text(self[s.name])
585         if f:
586             f.close()
587
588
589 class _YAMLDumper (_yaml.SafeDumper):
590     def represent_bool(self, data):
591         "Use yes/no instead of the default true/false"
592         if data:
593             value = u'yes'
594         else:
595             value = u'no'
596         return self.represent_scalar(u'tag:yaml.org,2002:bool', value)
597
598
599 _YAMLDumper.add_representer(bool, _YAMLDumper.represent_bool)
600
601
602 class _YAMLConfig (_BackedConfig):
603     """Mixin to back a `_Config` class with a YAML file.
604
605     TODO: Special handling for Choice (enums), FloatList (arrays), etc.?
606
607     >>> import os
608     >>> import os.path
609     >>> import tempfile
610     >>> fd,filename = tempfile.mkstemp(suffix='.yaml', prefix='pypiezo-')
611     >>> os.close(fd)
612
613     >>> c = YAML_BaseConfig(filename=filename)
614     >>> c.load()
615
616     Saving writes all the config values to disk.
617
618     >>> c['syslog'] = True
619     >>> c.save()
620     >>> print open(c.filename, 'r').read()
621     log-level: warn
622     matplotlib: no
623     syslog: yes
624     <BLANKLINE>
625
626     Loading reads the config files from disk.
627
628     >>> c = YAML_BaseConfig(filename=filename)
629     >>> c.load()
630     >>> print c.dump()
631     syslog: yes
632     matplotlib: no
633     log-level: warn
634
635     Cleanup our temporary config file.
636
637     >>> os.remove(filename)
638     """
639     dumper = _YAMLDumper
640
641     def __init__(self, filename, **kwargs):
642         super(_YAMLConfig, self).__init__(**kwargs)
643         self.filename = filename
644
645     def load(self):
646         if not _os_path.exists(self.filename):
647             open(self.filename, 'a').close()
648         with open(self.filename, 'r') as f:
649             data = _yaml.safe_load(f)
650         if data == None:
651             return  # empty file
652         settings = dict([(s.name, s) for s in self.settings])
653         for key,value in data.iteritems():
654             setting = settings[key]
655             if isinstance(setting, _BooleanSetting):
656                 v = value
657             else:
658                 v = setting.convert_from_text(value)
659             self[key] = v
660
661     def save(self):
662         data = {}
663         settings = dict([(s.name, s) for s in self.settings])
664         for key,value in self.iteritems():
665             if key in settings:
666                 setting = settings[key]
667                 if isinstance(setting, _BooleanSetting):
668                     v = value
669                 else:
670                     v = setting.convert_to_text(value)
671                 data[key] = v
672         with open(self.filename, 'w') as f:
673             _yaml.dump(data, stream=f, Dumper=self.dumper,
674                        default_flow_style=False)
675
676
677 # Define HDF5- and YAML-backed subclasses of the basic _Config types.
678 for name,obj in locals().items():
679     if (obj != _Config and
680         type(obj) == type and
681         issubclass(obj, _Config) and
682         not issubclass(obj, _BackedConfig)):
683         for prefix,base in [('HDF5', _HDF5Config), ('YAML', _YAMLConfig)]:
684             _name = '%s%s' % (prefix, name)
685             _bases = (base, obj)
686             _dict = {}
687             _class = type(_name, _bases, _dict)
688             setattr(_sys.modules[__name__], _name, _class)
689
690 del name, obj, prefix, base, _name, _bases, _dict, _class
691
692
693 def find_base_config():
694     "Return the best `_BaseConfig` match after scanning the filesystem"
695     _LOG.info('looking for base_config file')
696     user_basepath = _os_path.join(_os_path.expanduser('~'), '.pypiezorc')
697     system_basepath = _os_path.join('/etc', 'pypiezo', 'config')
698     distributed_basepath =  _os_path.join('/usr', 'share', 'pypiezo', 'config')
699     for basepath in [user_basepath, system_basepath, distributed_basepath]:
700         for (extension, config) in [('.h5', HDF5_BaseConfig),
701                                     ('.yaml', YAML_BaseConfig)]:
702             filename = basepath + extension
703             if _os_path.exists(filename):
704                 _LOG.info('base_config file found at %s' % filename)
705                 base_config = config(filename)
706                 base_config.load()
707                 return base_config
708             else:
709                 _LOG.debug('no base_config file at %s' % filename)
710     _LOG.info('new base_config file at %s' % filename)
711     basepath = user_basepath
712     filename = basepath + extension
713     return config(filename)