Clear NoteIndexList in .__setstate__ to avoid doubling items during engine->UI passing.
[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 = ['data']
206         self._item_default_attrs = {
207             'command_stack': [],
208             'driver': None,
209             'info': {},
210             'name': None,
211             }
212         self._loaded = [] # List of loaded curves, see :meth:`._setup_item`.
213
214     def _item_getstate(self, item):
215         assert isinstance(item, curve.Curve), type(item)
216         state = item.__getstate__()
217         for key in self._item_ignored_attrs:
218             if key in state:
219                 del(state[key])
220         for key,value in self._item_default_attrs.items():
221             if key in state and state[key] == value:
222                 del(state[key])
223         return state
224
225     def _item_setstate(self, state):
226         for key,value in self._item_default_attrs.items():
227             if key not in state:
228                 state[key] = value
229         item = curve.Curve(path=None)
230         item.__setstate__(state)
231         return item
232
233     def append_curve_by_path(self, path, info=None, identify=True, hooke=None):
234         path = os.path.normpath(path)
235         c = curve.Curve(path, info=info)
236         c.set_hooke(hooke)
237         if identify == True:
238             c.identify(self.drivers)
239         self.append(c)
240         return c
241
242     def _setup_item(self, curve):
243         if curve != None and curve not in self._loaded:
244             if curve not in self:
245                 self.append(curve)
246             if curve.driver == None:
247                 c.identify(self.drivers)
248             if curve.data == None:
249                 curve.load()
250             self._loaded.append(curve)
251             if len(self._loaded) > self._max_loaded:
252                 oldest = self._loaded.pop(0)
253                 oldest.unload()
254
255
256 class FilePlaylist (Playlist):
257     """A file-backed :class:`Playlist`.
258     """
259     version = '0.2'
260
261     def __init__(self, drivers, name=None, path=None):
262         super(FilePlaylist, self).__init__(drivers, name)
263         self.path = self._base_path = None
264         self.set_path(path)
265         self._relative_curve_paths = True
266
267     def _set_ignored_attrs(self):
268         super(FilePlaylist, self)._set_ignored_attrs()
269         self._ignored_attrs.append('_digest')
270         self._digest = None
271
272     def __getstate__(self):
273         state = super(FilePlaylist, self).__getstate__()
274         assert 'version' not in state, state
275         state['version'] = self.version
276         return state
277
278     def __setstate__(self, state):
279         assert('version') in state, state
280         version = state.pop('version')
281         assert version == FilePlaylist.version, (
282             'invalid version %s (%s) != %s (%s)'
283             % (version, type(version),
284                FilePlaylist.version, type(FilePlaylist.version)))
285         super(FilePlaylist, self).__setstate__(state)
286
287     def _item_getstate(self, item):
288         state = super(FilePlaylist, self)._item_getstate(item)
289         if state.get('path', None) != None:
290             path = os.path.abspath(os.path.expanduser(state['path']))
291             if self._relative_curve_paths == True:
292                 path = os.path.relpath(path, self._base_path)
293             state['path'] = path
294         return state
295
296     def _item_setstate(self, state):
297         item = super(FilePlaylist, self)._item_setstate(state)
298         if 'path' in state:
299             item.set_path(os.path.join(self._base_path, state['path']))
300         return item
301
302     def set_path(self, path):
303         if path == None:
304             if self._base_path == None:
305                 self._base_path = os.getcwd()
306         else:
307             if not path.endswith('.hkp'):
308                 path += '.hkp'
309             self.path = path
310             self._base_path = os.path.dirname(os.path.abspath(
311                 os.path.expanduser(self.path)))
312             if self.name == None:
313                 self.name = os.path.basename(path)
314
315     def append_curve_by_path(self, path, *args, **kwargs):
316         if self._base_path != None:
317             path = os.path.join(self._base_path, path)
318         super(FilePlaylist, self).append_curve_by_path(path, *args, **kwargs)
319
320     def is_saved(self):
321         return self.digest() == self._digest
322
323     def digest(self):
324         r"""Compute the sha1 digest of the flattened playlist
325         representation.
326
327         Examples
328         --------
329
330         >>> root_path = os.path.sep + 'path'
331         >>> p = FilePlaylist(drivers=[],
332         ...                  path=os.path.join(root_path, 'to','playlist'))
333         >>> p.info['note'] = 'An example playlist'
334         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
335         >>> c.info['note'] = 'The first curve'
336         >>> p.append(c)
337         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
338         >>> c.info['note'] = 'The second curve'
339         >>> p.append(c)
340         >>> p.digest()
341         '\xa1\x1ax\xb1|\x84uA\xe4\x1d\xbf`\x004|\x82\xc2\xdd\xc1\x9e'
342         """
343         string = self.flatten()
344         return hashlib.sha1(string).digest()
345
346     def flatten(self):
347         """Create a string representation of the playlist.
348
349         A playlist is a YAML document with the following minimal syntax::
350
351             version: '0.2'
352             items:
353             - path: picoforce.000
354             - path: picoforce.001
355
356         Relative paths are interpreted relative to the location of the
357         playlist file.
358
359         Examples
360         --------
361
362         >>> from .engine import CommandMessage
363
364         >>> root_path = os.path.sep + 'path'
365         >>> p = FilePlaylist(drivers=[],
366         ...                  path=os.path.join(root_path, 'to','playlist'))
367         >>> p.info['note'] = 'An example playlist'
368         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
369         >>> c.info['note'] = 'The first curve'
370         >>> p.append(c)
371         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
372         >>> c.info['attr with spaces'] = 'The second curve\\nwith endlines'
373         >>> c.command_stack.extend([
374         ...         CommandMessage('command A', {'arg 0':0, 'arg 1':'X'}),
375         ...         CommandMessage('command B', {'arg 0':1, 'arg 1':'Y'}),
376         ...         ])
377         >>> p.append(c)
378         >>> print p.flatten()  # doctest: +REPORT_UDIFF
379         # Hooke playlist version 0.2
380         _base_path: /path/to
381         _index: 0
382         _max_loaded: 100
383         _relative_curve_paths: true
384         drivers: []
385         info: {note: An example playlist}
386         items:
387         - info: {note: The first curve}
388           name: one
389           path: curve/one
390         - command_stack: !!python/object/new:hooke.command_stack.CommandStack
391             listitems:
392             - !!python/object:hooke.engine.CommandMessage
393               arguments: {arg 0: 0, arg 1: X}
394               command: command A
395             - !!python/object:hooke.engine.CommandMessage
396               arguments: {arg 0: 1, arg 1: Y}
397               command: command B
398           info: {attr with spaces: 'The second curve
399         <BLANKLINE>
400               with endlines'}
401           name: two
402           path: curve/two
403         name: playlist.hkp
404         path: /path/to/playlist.hkp
405         version: '0.2'
406         <BLANKLINE>
407         >>> p._relative_curve_paths = False
408         >>> print p.flatten()  # doctest: +REPORT_UDIFF
409         # Hooke playlist version 0.2
410         _base_path: /path/to
411         _index: 0
412         _max_loaded: 100
413         _relative_curve_paths: false
414         drivers: []
415         info: {note: An example playlist}
416         items:
417         - info: {note: The first curve}
418           name: one
419           path: /path/to/curve/one
420         - command_stack: !!python/object/new:hooke.command_stack.CommandStack
421             listitems:
422             - !!python/object:hooke.engine.CommandMessage
423               arguments: {arg 0: 0, arg 1: X}
424               command: command A
425             - !!python/object:hooke.engine.CommandMessage
426               arguments: {arg 0: 1, arg 1: Y}
427               command: command B
428           info: {attr with spaces: 'The second curve
429         <BLANKLINE>
430               with endlines'}
431           name: two
432           path: /path/to/curve/two
433         name: playlist.hkp
434         path: /path/to/playlist.hkp
435         version: '0.2'
436         <BLANKLINE>
437         """
438         yaml_string = yaml.dump(self.__getstate__(), allow_unicode=True)
439         return ('# Hooke playlist version %s\n' % self.version) + yaml_string
440
441     def from_string(self, string):
442         u"""Load a playlist from a string.
443
444         Examples
445         --------
446
447         Minimal example.
448
449         >>> string = '''# Hooke playlist version 0.2
450         ... version: '0.2'
451         ... items:
452         ... - path: picoforce.000
453         ... - path: picoforce.001
454         ... '''
455         >>> p = FilePlaylist(drivers=[],
456         ...                 path=os.path.join('/path', 'to', 'my', 'playlist'))
457         >>> p.from_string(string)
458         >>> for curve in p:
459         ...     print curve.path
460         /path/to/my/picoforce.000
461         /path/to/my/picoforce.001
462
463         More complicated example.
464
465         >>> string = '''# Hooke playlist version 0.2
466         ... _base_path: /path/to
467         ... _digest: null
468         ... _index: 1
469         ... _max_loaded: 100
470         ... _relative_curve_paths: true
471         ... info: {note: An example playlist}
472         ... items:
473         ... - info: {note: The first curve}
474         ...   path: curve/one
475         ... - command_stack: !!python/object/new:hooke.command_stack.CommandStack
476         ...      listitems:
477         ...      - !!python/object:hooke.engine.CommandMessage
478         ...        arguments: {arg 0: 0, arg 1: X}
479         ...        command: command A
480         ...      - !!python/object:hooke.engine.CommandMessage
481         ...        arguments: {arg 0: 1, arg 1: Y}
482         ...        command: command B
483         ...   info: {attr with spaces: 'The second curve
484         ... 
485         ...       with endlines'}
486         ...   name: two
487         ...   path: curve/two
488         ... name: playlist.hkp
489         ... path: /path/to/playlist.hkp
490         ... version: '0.2'
491         ... '''
492         >>> p = FilePlaylist(drivers=[],
493         ...                  path=os.path.join('path', 'to', 'my', 'playlist'))
494         >>> p.from_string(string)
495         >>> p._index
496         1
497         >>> p.info
498         {'note': 'An example playlist'}
499         >>> for curve in p:
500         ...     print curve.name, curve.path
501         one /path/to/curve/one
502         two /path/to/curve/two
503         >>> p[-1].info['attr with spaces']
504         'The second curve\\nwith endlines'
505         >>> type(p[-1].command_stack)
506         <class 'hooke.command_stack.CommandStack'>
507         >>> p[-1].command_stack  # doctest: +NORMALIZE_WHITESPACE
508         [<CommandMessage command A {arg 0: 0, arg 1: X}>,
509          <CommandMessage command B {arg 0: 1, arg 1: Y}>]
510         """
511         state = yaml.load(string)
512         self.__setstate__(state)
513
514     def save(self, path=None, makedirs=True):
515         """Saves the playlist to a YAML file.
516         """
517         self.set_path(path)
518         dirname = os.path.dirname(self.path) or '.'
519         if makedirs == True and not os.path.isdir(dirname):
520             os.makedirs(dirname)
521         with open(self.path, 'w') as f:
522             f.write(self.flatten())
523             self._digest = self.digest()
524
525     def load(self, path=None, identify=True, hooke=None):
526         """Load a playlist from a file.
527         """
528         self.set_path(path)
529         with open(self.path, 'r') as f:
530             text = f.read()
531         self.from_string(text)
532         self._digest = self.digest()
533         for curve in self:
534             curve.set_hooke(hooke)
535             if identify == True:
536                 curve.identify(self.drivers)
537
538
539 class Playlists (NoteIndexList):
540     """A :class:`NoteIndexList` of :class:`FilePlaylist`\s.
541     """
542     def __init__(self, *arg, **kwargs):
543         super(Playlists, self).__init__(*arg, **kwargs)
544
545     def _item_getstate(self, item):
546         assert isinstance(item, FilePlaylist), type(item)
547         return item.__getstate__()
548
549     def _item_setstate(self, state):
550         item = FilePlaylist(drivers=[])
551         item.__setstate__(state)
552         return item