Tracked down problems saving playlists with loaded curves to numpy.dtype
[hooke.git] / hooke / playlist.py
1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of Hooke.
4 #
5 # Hooke is free software: you can redistribute it and/or modify it
6 # under the terms of the GNU Lesser General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # Hooke is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12 # or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General
13 # Public License for more details.
14 #
15 # You should have received a copy of the GNU Lesser General Public
16 # License along with Hooke.  If not, see
17 # <http://www.gnu.org/licenses/>.
18
19 """The `playlist` module provides a :class:`Playlist` and its subclass
20 :class:`FilePlaylist` for manipulating lists of
21 :class:`hooke.curve.Curve`\s.
22 """
23
24 import copy
25 import hashlib
26 import os
27 import os.path
28 import types
29
30 if False: # YAML dump debugging code
31     """To help isolate data types etc. that give YAML problems.
32
33     This is usually caused by external C modules (e.g. numpy) that
34     define new types (e.g. numpy.dtype) which YAML cannot inspect.
35     """
36     import yaml.representer
37     import sys
38     def ignore_aliases(data):
39         print data, type(data)
40         sys.stdout.flush()
41         if data in [None, ()]:
42             return True
43         if isinstance(data, (str, unicode, bool, int, float)):
44             return True
45     yaml.representer.SafeRepresenter.ignore_aliases = staticmethod(
46         ignore_aliases)
47
48 import yaml
49 from yaml.representer import RepresenterError
50
51 from . import curve as curve
52 from .util.itertools import reverse_enumerate
53
54
55 class NoteIndexList (list):
56     """A list that keeps track of a "current" item and additional notes.
57
58     :attr:`index` (i.e. "bookmark") is the index of the currently
59     current curve.  Also keep a :class:`dict` of additional information
60     (:attr:`info`).
61     """
62     def __init__(self, name=None):
63         super(NoteIndexList, self).__init__()
64         self.name = name
65         self.info = {}
66         self._index = 0
67         self._set_ignored_attrs()
68
69     def __str__(self):
70         return str(self.__unicode__())
71
72     def __unicode__(self):
73         return u'<%s %s>' % (self.__class__.__name__, self.name)
74
75     def __repr__(self):
76         return self.__str__()
77
78     def _set_ignored_attrs(self):
79         self._ignored_attrs = ['_ignored_attrs', '_default_attrs']
80         self._default_attrs = {
81             'info': {},
82             }
83
84     def __getstate__(self):
85         state = dict(self.__dict__)
86         for key in self._ignored_attrs:
87             if key in state:
88                 del(state[key])
89         for key,value in self._default_attrs.items():
90             if key in state and state[key] == value:
91                 del(state[key])
92         assert 'items' not in state
93         state['items'] = []
94         self._assert_clean_state(self, state)
95         for item in self:  # save curves and their attributes
96             item_state = self._item_getstate(item)
97             self._assert_clean_state(item, item_state)
98             state['items'].append(item_state)
99         return state
100
101     def __setstate__(self, state):
102         self._set_ignored_attrs()
103         for key,value in self._default_attrs.items():
104             setattr(self, key, value)
105         for key,value in state.items():
106             if key == 'items':
107                 continue
108             setattr(self, key, value)
109         for item_state in state['items']:
110             self.append(self._item_setstate(item_state))
111
112     def _item_getstate(self, item):
113         return item
114
115     def _item_setstate(self, state):
116         return state
117
118     def _assert_clean_state(self, owner, state):
119         for k,v in state.items():
120             if k == 'drivers':  # HACK.  Need better driver serialization.
121                 continue
122             try:
123                 yaml.dump((k,v))
124             except RepresenterError, e:
125                 raise NotImplementedError(
126                     'cannot convert %s.%s = %s (%s) to YAML\n%s'
127                     % (owner.__class__.__name__, k, v, type(v), e))
128
129     def _setup_item(self, item):
130         """Perform any required initialization before returning an item.
131         """
132         pass
133
134     def index(self, value=None, *args, **kwargs):
135         """Extend `list.index`, returning the current index if `value`
136         is `None`.
137         """
138         if value == None:
139             return self._index
140         return super(NoteIndexList, self).index(value, *args, **kwargs)
141
142     def current(self):
143         if len(self) == 0:
144             return None
145         item = self[self._index]
146         self._setup_item(item)
147         return item
148
149     def jump(self, index):
150         if len(self) == 0:
151             self._index = 0
152         else:
153             self._index = index % len(self)
154
155     def next(self):
156         self.jump(self._index + 1)
157
158     def previous(self):
159         self.jump(self._index - 1)
160
161     def items(self, reverse=False):
162         """Iterate through `self` calling `_setup_item` on each item
163         before yielding.
164
165         Notes
166         -----
167         Updates :attr:`_index` during the iteration so
168         :func:`~hooke.plugin.curve.current_curve_callback` works as
169         expected in :class:`~hooke.command.Command`\s called from
170         :class:`~hooke.plugin.playlist.ApplyCommand`.  After the
171         iteration completes, :attr:`_index` is restored to its
172         original value.
173         """
174         index = self._index
175         items = self
176         if reverse == True:
177             items = reverse_enumerate(self)
178         else:
179             items = enumerate(self)
180         for i,item in items:
181             self._index = i
182             self._setup_item(item)
183             yield item
184         self._index = index
185
186     def filter(self, keeper_fn=lambda item:True, load_curves=True,
187                *args, **kwargs):
188         c = copy.deepcopy(self)
189         if load_curves == True:
190             items = c.items(reverse=True)
191         else:
192             items = reversed(c)
193         for item in items: 
194             if keeper_fn(item, *args, **kwargs) != True:
195                 c.remove(item)
196         try: # attempt to maintain the same current item
197             c._index = c.index(self.current())
198         except ValueError:
199             c._index = 0
200         return c
201
202
203 class Playlist (NoteIndexList):
204     """A :class:`NoteIndexList` of :class:`hooke.curve.Curve`\s.
205
206     Keeps a list of :attr:`drivers` for loading curves.
207     """
208     def __init__(self, drivers, name=None):
209         super(Playlist, self).__init__(name=name)
210         self.drivers = drivers
211         self._max_loaded = 100 # curves to hold in memory simultaneously.
212
213     def _set_ignored_attrs(self):
214         super(Playlist, self)._set_ignored_attrs()
215         self._ignored_attrs.extend([
216                 '_item_ignored_attrs', '_item_default_attrs',
217                 '_loaded'])
218         self._item_ignored_attrs = ['data']
219         self._item_default_attrs = {
220             'command_stack': [],
221             'driver': None,
222             'info': {},
223             'name': None,
224             }
225         self._loaded = [] # List of loaded curves, see :meth:`._setup_item`.
226
227     def _item_getstate(self, item):
228         assert isinstance(item, curve.Curve), type(item)
229         state = item.__getstate__()
230         for key in self._item_ignored_attrs:
231             if key in state:
232                 del(state[key])
233         for key,value in self._item_default_attrs.items():
234             if key in state and state[key] == value:
235                 del(state[key])
236         return state
237
238     def _item_setstate(self, state):
239         for key,value in self._item_default_attrs.items():
240             if key not in state:
241                 state[key] = value
242         item = curve.Curve(path=None)
243         item.__setstate__(state)
244         return item
245
246     def append_curve_by_path(self, path, info=None, identify=True, hooke=None):
247         path = os.path.normpath(path)
248         c = curve.Curve(path, info=info)
249         c.set_hooke(hooke)
250         if identify == True:
251             c.identify(self.drivers)
252         self.append(c)
253         return c
254
255     def _setup_item(self, curve):
256         if curve != None and curve not in self._loaded:
257             if curve not in self:
258                 self.append(curve)
259             if curve.driver == None:
260                 c.identify(self.drivers)
261             if curve.data == None:
262                 curve.load()
263             self._loaded.append(curve)
264             if len(self._loaded) > self._max_loaded:
265                 oldest = self._loaded.pop(0)
266                 oldest.unload()
267
268
269 class FilePlaylist (Playlist):
270     """A file-backed :class:`Playlist`.
271     """
272     version = '0.2'
273
274     def __init__(self, drivers, name=None, path=None):
275         super(FilePlaylist, self).__init__(drivers, name)
276         self.path = self._base_path = None
277         self.set_path(path)
278         self._relative_curve_paths = True
279
280     def _set_ignored_attrs(self):
281         super(FilePlaylist, self)._set_ignored_attrs()
282         self._ignored_attrs.append('_digest')
283         self._digest = None
284
285     def __getstate__(self):
286         state = super(FilePlaylist, self).__getstate__()
287         assert 'version' not in state, state
288         state['version'] = self.version
289         return state
290
291     def __setstate__(self, state):
292         assert('version') in state, state
293         version = state.pop('version')
294         assert version == FilePlaylist.version, (
295             'invalid version %s (%s) != %s (%s)'
296             % (version, type(version),
297                FilePlaylist.version, type(FilePlaylist.version)))
298         super(FilePlaylist, self).__setstate__(state)
299
300     def _item_getstate(self, item):
301         state = super(FilePlaylist, self)._item_getstate(item)
302         if state.get('path', None) != None:
303             path = os.path.abspath(os.path.expanduser(state['path']))
304             if self._relative_curve_paths == True:
305                 path = os.path.relpath(path, self._base_path)
306             state['path'] = path
307         return state
308
309     def _item_setstate(self, state):
310         item = super(FilePlaylist, self)._item_setstate(state)
311         if 'path' in state:
312             item.set_path(os.path.join(self._base_path, state['path']))
313         return item
314
315     def set_path(self, path):
316         if path == None:
317             if self._base_path == None:
318                 self._base_path = os.getcwd()
319         else:
320             if not path.endswith('.hkp'):
321                 path += '.hkp'
322             self.path = path
323             self._base_path = os.path.dirname(os.path.abspath(
324                 os.path.expanduser(self.path)))
325             if self.name == None:
326                 self.name = os.path.basename(path)
327
328     def append_curve_by_path(self, path, *args, **kwargs):
329         if self._base_path != None:
330             path = os.path.join(self._base_path, path)
331         super(FilePlaylist, self).append_curve_by_path(path, *args, **kwargs)
332
333     def is_saved(self):
334         return self.digest() == self._digest
335
336     def digest(self):
337         r"""Compute the sha1 digest of the flattened playlist
338         representation.
339
340         Examples
341         --------
342
343         >>> root_path = os.path.sep + 'path'
344         >>> p = FilePlaylist(drivers=[],
345         ...                  path=os.path.join(root_path, 'to','playlist'))
346         >>> p.info['note'] = 'An example playlist'
347         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
348         >>> c.info['note'] = 'The first curve'
349         >>> p.append(c)
350         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
351         >>> c.info['note'] = 'The second curve'
352         >>> p.append(c)
353         >>> p.digest()
354         '\xa1\x1ax\xb1|\x84uA\xe4\x1d\xbf`\x004|\x82\xc2\xdd\xc1\x9e'
355         """
356         string = self.flatten()
357         return hashlib.sha1(string).digest()
358
359     def flatten(self):
360         """Create a string representation of the playlist.
361
362         A playlist is a YAML document with the following minimal syntax::
363
364             version: '0.2'
365             items:
366             - path: picoforce.000
367             - path: picoforce.001
368
369         Relative paths are interpreted relative to the location of the
370         playlist file.
371
372         Examples
373         --------
374
375         >>> from .engine import CommandMessage
376
377         >>> root_path = os.path.sep + 'path'
378         >>> p = FilePlaylist(drivers=[],
379         ...                  path=os.path.join(root_path, 'to','playlist'))
380         >>> p.info['note'] = 'An example playlist'
381         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
382         >>> c.info['note'] = 'The first curve'
383         >>> p.append(c)
384         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
385         >>> c.info['attr with spaces'] = 'The second curve\\nwith endlines'
386         >>> c.command_stack.extend([
387         ...         CommandMessage('command A', {'arg 0':0, 'arg 1':'X'}),
388         ...         CommandMessage('command B', {'arg 0':1, 'arg 1':'Y'}),
389         ...         ])
390         >>> p.append(c)
391         >>> print p.flatten()  # doctest: +REPORT_UDIFF
392         # Hooke playlist version 0.2
393         _base_path: /path/to
394         _index: 0
395         _max_loaded: 100
396         _relative_curve_paths: true
397         drivers: []
398         info: {note: An example playlist}
399         items:
400         - info: {note: The first curve}
401           name: one
402           path: curve/one
403         - command_stack: !!python/object/new:hooke.command_stack.CommandStack
404             listitems:
405             - !!python/object:hooke.engine.CommandMessage
406               arguments: {arg 0: 0, arg 1: X}
407               command: command A
408             - !!python/object:hooke.engine.CommandMessage
409               arguments: {arg 0: 1, arg 1: Y}
410               command: command B
411           info: {attr with spaces: 'The second curve
412         <BLANKLINE>
413               with endlines'}
414           name: two
415           path: curve/two
416         name: playlist.hkp
417         path: /path/to/playlist.hkp
418         version: '0.2'
419         <BLANKLINE>
420         >>> p._relative_curve_paths = False
421         >>> print p.flatten()  # doctest: +REPORT_UDIFF
422         # Hooke playlist version 0.2
423         _base_path: /path/to
424         _index: 0
425         _max_loaded: 100
426         _relative_curve_paths: false
427         drivers: []
428         info: {note: An example playlist}
429         items:
430         - info: {note: The first curve}
431           name: one
432           path: /path/to/curve/one
433         - command_stack: !!python/object/new:hooke.command_stack.CommandStack
434             listitems:
435             - !!python/object:hooke.engine.CommandMessage
436               arguments: {arg 0: 0, arg 1: X}
437               command: command A
438             - !!python/object:hooke.engine.CommandMessage
439               arguments: {arg 0: 1, arg 1: Y}
440               command: command B
441           info: {attr with spaces: 'The second curve
442         <BLANKLINE>
443               with endlines'}
444           name: two
445           path: /path/to/curve/two
446         name: playlist.hkp
447         path: /path/to/playlist.hkp
448         version: '0.2'
449         <BLANKLINE>
450         """
451         yaml_string = yaml.dump(self.__getstate__(), allow_unicode=True)
452         return ('# Hooke playlist version %s\n' % self.version) + yaml_string
453
454     def from_string(self, string):
455         u"""Load a playlist from a string.
456
457         Examples
458         --------
459
460         Minimal example.
461
462         >>> string = '''# Hooke playlist version 0.2
463         ... version: '0.2'
464         ... items:
465         ... - path: picoforce.000
466         ... - path: picoforce.001
467         ... '''
468         >>> p = FilePlaylist(drivers=[],
469         ...                 path=os.path.join('/path', 'to', 'my', 'playlist'))
470         >>> p.from_string(string)
471         >>> for curve in p:
472         ...     print curve.path
473         /path/to/my/picoforce.000
474         /path/to/my/picoforce.001
475
476         More complicated example.
477
478         >>> string = '''# Hooke playlist version 0.2
479         ... _base_path: /path/to
480         ... _digest: null
481         ... _index: 1
482         ... _max_loaded: 100
483         ... _relative_curve_paths: true
484         ... info: {note: An example playlist}
485         ... items:
486         ... - info: {note: The first curve}
487         ...   path: curve/one
488         ... - command_stack: !!python/object/new:hooke.command_stack.CommandStack
489         ...      listitems:
490         ...      - !!python/object:hooke.engine.CommandMessage
491         ...        arguments: {arg 0: 0, arg 1: X}
492         ...        command: command A
493         ...      - !!python/object:hooke.engine.CommandMessage
494         ...        arguments: {arg 0: 1, arg 1: Y}
495         ...        command: command B
496         ...   info: {attr with spaces: 'The second curve
497         ... 
498         ...       with endlines'}
499         ...   name: two
500         ...   path: curve/two
501         ... name: playlist.hkp
502         ... path: /path/to/playlist.hkp
503         ... version: '0.2'
504         ... '''
505         >>> p = FilePlaylist(drivers=[],
506         ...                  path=os.path.join('path', 'to', 'my', 'playlist'))
507         >>> p.from_string(string)
508         >>> p._index
509         1
510         >>> p.info
511         {'note': 'An example playlist'}
512         >>> for curve in p:
513         ...     print curve.name, curve.path
514         one /path/to/curve/one
515         two /path/to/curve/two
516         >>> p[-1].info['attr with spaces']
517         'The second curve\\nwith endlines'
518         >>> type(p[-1].command_stack)
519         <class 'hooke.command_stack.CommandStack'>
520         >>> p[-1].command_stack  # doctest: +NORMALIZE_WHITESPACE
521         [<CommandMessage command A {arg 0: 0, arg 1: X}>,
522          <CommandMessage command B {arg 0: 1, arg 1: Y}>]
523         """
524         state = yaml.load(string)
525         self.__setstate__(state)
526
527     def save(self, path=None, makedirs=True):
528         """Saves the playlist to a YAML file.
529         """
530         self.set_path(path)
531         dirname = os.path.dirname(self.path) or '.'
532         if makedirs == True and not os.path.isdir(dirname):
533             os.makedirs(dirname)
534         with open(self.path, 'w') as f:
535             f.write(self.flatten())
536             self._digest = self.digest()
537
538     def load(self, path=None, identify=True, hooke=None):
539         """Load a playlist from a file.
540         """
541         self.set_path(path)
542         with open(self.path, 'r') as f:
543             text = f.read()
544         self.from_string(text)
545         self._digest = self.digest()
546         for curve in self:
547             curve.set_hooke(hooke)
548             if identify == True:
549                 curve.identify(self.drivers)
550
551
552 class Playlists (NoteIndexList):
553     """A :class:`NoteIndexList` of :class:`FilePlaylist`\s.
554     """
555     def __init__(self, *arg, **kwargs):
556         super(Playlists, self).__init__(*arg, **kwargs)
557
558     def _item_getstate(self, item):
559         assert isinstance(item, FilePlaylist), type(item)
560         return item.__getstate__()
561
562     def _item_setstate(self, state):
563         item = FilePlaylist(drivers=[])
564         item.__setstate__(state)
565         return item