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