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