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