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