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