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