Added command stack saving and loading.
[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 import xml.dom.minidom
30
31 from . import curve as curve
32 from .compat import minidom as minidom  # dynamically patch xml.sax.minidom
33
34
35 class NoteIndexList (list):
36     """A list that keeps track of a "current" item and additional notes.
37
38     :attr:`index` (i.e. "bookmark") is the index of the currently
39     current curve.  Also keep a :class:`dict` of additional information
40     (:attr:`info`).
41     """
42     def __init__(self, name=None):
43         super(NoteIndexList, self).__init__()
44         self.name = name
45         self.info = {}
46         self._index = 0
47
48     def __str__(self):
49         return str(self.__unicode__())
50
51     def __unicode__(self):
52         return u'<%s %s>' % (self.__class__.__name__, self.name)
53
54     def __repr__(self):
55         return self.__str__()
56
57     def _setup_item(self, item):
58         """Perform any required initialization before returning an item.
59         """
60         pass
61
62     def index(self, value=None, *args, **kwargs):
63         """Extend `list.index`, returning the current index if `value`
64         is `None`.
65         """
66         if value == None:
67             return self._index
68         return super(NoteIndexList, self).index(value, *args, **kwargs)
69
70     def current(self):
71         if len(self) == 0:
72             return None
73         item = self[self._index]
74         self._setup_item(item)
75         return item
76
77     def jump(self, index):
78         if len(self) == 0:
79             self._index = 0
80         else:
81             self._index = index % len(self)
82
83     def next(self):
84         self.jump(self._index + 1)
85
86     def previous(self):
87         self.jump(self._index - 1)
88
89     def items(self, reverse=False):
90         """Iterate through `self` calling `_setup_item` on each item
91         before yielding.
92
93         Notes
94         -----
95         Updates :attr:`_index` during the iteration so
96         :func:`~hooke.plugin.curve.current_curve_callback` works as
97         expected in :class:`~hooke.command.Command`\s called from
98         :class:`~hooke.plugin.playlist.ApplyCommandStack`.  After the
99         iteration completes, :attr:`_index` is restored to its
100         original value.
101         """
102         index = self._index
103         items = self
104         if reverse == True:
105             items = reversed(enumerate(self))
106         else:
107             items = enumerate(self)
108         for i,item in items:
109             self._index = i
110             self._setup_item(item)
111             yield item
112         self._index = index
113
114     def filter(self, keeper_fn=lambda item:True, *args, **kwargs):
115         c = copy.deepcopy(self)
116         for item in c.items(reverse=True):
117             if keeper_fn(item, *args, **kwargs) != True:
118                 c.remove(item)
119         try: # attempt to maintain the same current item
120             c._index = c.index(self.current())
121         except ValueError:
122             c._index = 0
123         return c
124
125
126 class Playlist (NoteIndexList):
127     """A :class:`NoteIndexList` of :class:`hooke.curve.Curve`\s.
128
129     Keeps a list of :attr:`drivers` for loading curves.
130     """
131     def __init__(self, drivers, name=None):
132         super(Playlist, self).__init__(name=name)
133         self.drivers = drivers
134         self._loaded = [] # List of loaded curves, see :meth:`._setup_item`.
135         self._max_loaded = 100 # curves to hold in memory simultaneously.
136
137     def append_curve_by_path(self, path, info=None, identify=True, hooke=None):
138         path = os.path.normpath(path)
139         c = curve.Curve(path, info=info)
140         c.set_hooke(hooke)
141         if identify == True:
142             c.identify(self.drivers)
143         self.append(c)
144         return c
145
146     def _setup_item(self, curve):
147         if curve != None and curve not in self._loaded:
148             if curve not in self:
149                 self.append(curve)
150             if curve.driver == None:
151                 c.identify(self.drivers)
152             if curve.data == None:
153                 curve.load()
154             self._loaded.append(curve)
155             if len(self._loaded) > self._max_loaded:
156                 oldest = self._loaded.pop(0)
157                 oldest.unload()
158
159
160 class FilePlaylist (Playlist):
161     """A file-backed :class:`Playlist`.
162     """
163     version = '0.1'
164
165     def __init__(self, drivers, name=None, path=None):
166         super(FilePlaylist, self).__init__(drivers, name)
167         self.path = None
168         self.set_path(path)
169         self._digest = None
170         self._ignored_keys = [
171             'experiment',  # class instance, not very exciting.
172             ]
173
174     def set_path(self, path):
175         if path != None:
176             if not path.endswith('.hkp'):
177                 path += '.hkp'
178             self.path = path
179             if self.name == None:
180                 self.name = os.path.basename(path)
181
182     def append_curve_by_path(self, path, *args, **kwargs):
183         if self.path != None:
184             path = os.path.join(os.path.dirname(self.path), path)
185         super(FilePlaylist, self).append_curve_by_path(path, *args, **kwargs)
186
187     def is_saved(self):
188         return self.digest() == self._digest
189
190     def digest(self):
191         r"""Compute the sha1 digest of the flattened playlist
192         representation.
193
194         Examples
195         --------
196
197         >>> root_path = os.path.sep + 'path'
198         >>> p = FilePlaylist(drivers=[],
199         ...                  path=os.path.join(root_path, 'to','playlist'))
200         >>> p.info['note'] = 'An example playlist'
201         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
202         >>> c.info['note'] = 'The first curve'
203         >>> p.append(c)
204         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
205         >>> c.info['note'] = 'The second curve'
206         >>> p.append(c)
207         >>> p.digest()
208         '\\\x14\x87\x88*q\xf8\xaa\xa7\x84f\x82\xa1S>\xfd3+\xd0o'
209         """
210         string = self.flatten()
211         return hashlib.sha1(string).digest()
212
213     def _clean_key(self, key):
214         """Replace spaces in keys with \\u00B7 (middle dot).
215
216         This character is deemed unlikely to occur in keys to our
217         playlist and curve info dictionaries, while many keys have
218         spaces in them.
219
220         \\u00B7 is allowed in XML 1.0 as of the 5th edition.  See
221         the `4th edition errata`_ for details.
222
223         .. _4th edition errata:
224           http://www.w3.org/XML/xml-V10-4e-errata#E09
225         """
226         return key.replace(' ', u'\u00B7')
227
228     def _restore_key(self, key):
229         """Restore keys encoded with :meth:`_clean_key`.
230         """
231         return key.replace(u'\u00B7', ' ')
232
233     def flatten(self, absolute_paths=False):
234         """Create a string representation of the playlist.
235
236         A playlist is an XML document with the following syntax::
237
238             <?xml version="1.0" encoding="utf-8"?>
239             <playlist attribute="value">
240               <curve path="/my/file/path/"/ attribute="value" ...>
241               <curve path="...">
242             </playlist>
243
244         Relative paths are interpreted relative to the location of the
245         playlist file.
246
247         Examples
248         --------
249
250         >>> root_path = os.path.sep + 'path'
251         >>> p = FilePlaylist(drivers=[],
252         ...                  path=os.path.join(root_path, 'to','playlist'))
253         >>> p.info['note'] = 'An example playlist'
254         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
255         >>> c.info['note'] = 'The first curve'
256         >>> p.append(c)
257         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
258         >>> c.info['attr with spaces'] = 'The second curve\\nwith endlines'
259         >>> p.append(c)
260         >>> def _print(string):
261         ...     escaped_string = unicode(string, 'utf-8').encode('unicode escape')
262         ...     print escaped_string.replace('\\\\n', '\\n').replace('\\\\t', '\\t'),
263         >>> _print(p.flatten())  # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
264         <?xml version="1.0" encoding="utf-8"?>
265         <playlist index="0" note="An example playlist" version="0.1">
266            <curve note="The first curve" path="curve/one"/>
267            <curve attr\\xb7with\\xb7spaces="The second curve&#xA;with endlines" path="curve/two"/>
268         </playlist>
269         >>> _print(p.flatten(absolute_paths=True))  # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
270         <?xml version="1.0" encoding="utf-8"?>
271         <playlist index="0" note="An example playlist" version="0.1">
272            <curve note="The first curve" path="/path/to/curve/one"/>
273            <curve attr\\xb7with\\xb7spaces="The second curve&#xA;with endlines" path="/path/to/curve/two"/>
274         </playlist>
275         """
276         implementation = xml.dom.minidom.getDOMImplementation()
277         # create the document DOM object and the root element
278         doc = implementation.createDocument(None, 'playlist', None)
279         root = doc.documentElement
280         root.setAttribute('version', self.version) # store playlist version
281         root.setAttribute('index', str(self._index))
282         for key,value in self.info.items(): # save info variables
283             if (key in self._ignored_keys
284                 or not isinstance(value, types.StringTypes)):
285                 continue
286             root.setAttribute(self._clean_key(key), str(value))
287         for curve in self: # save curves and their attributes
288             curve_element = doc.createElement('curve')
289             root.appendChild(curve_element)
290             path = os.path.abspath(os.path.expanduser(curve.path))
291             if absolute_paths == False:
292                 path = os.path.relpath(
293                     path,
294                     os.path.dirname(
295                         os.path.abspath(
296                             os.path.expanduser(self.path))))
297             curve_element.setAttribute('path', path)
298             for key,value in curve.info.items():
299                 if (key in self._ignored_keys
300                     or not isinstance(value, types.StringTypes)):
301                     continue
302                 curve_element.setAttribute(self._clean_key(key), str(value))
303         string = doc.toprettyxml(encoding='utf-8')
304         root.unlink() # break circular references for garbage collection
305         return string
306
307     def _from_xml_doc(self, doc, identify=True):
308         """Load a playlist from an :class:`xml.dom.minidom.Document`
309         instance.
310         """
311         root = doc.documentElement
312         for attribute,value in root.attributes.items():
313             attribute = self._restore_key(attribute)
314             if attribute == 'version':
315                 assert value == self.version, \
316                     'Cannot read v%s playlist with a v%s reader' \
317                     % (value, self.version)
318             elif attribute == 'index':
319                 self._index = int(value)
320             else:
321                 self.info[attribute] = value
322         for curve_element in doc.getElementsByTagName('curve'):
323             path = curve_element.getAttribute('path')
324             info = dict([(self._restore_key(key), value)
325                          for key,value in curve_element.attributes.items()])
326             info.pop('path')
327             self.append_curve_by_path(path, info, identify=identify)
328         self.jump(self._index) # ensure valid index
329
330     def from_string(self, string, identify=True):
331         u"""Load a playlist from a string.
332
333         Examples
334         --------
335
336         >>> string = '''<?xml version="1.0" encoding="utf-8"?>
337         ... <playlist index="1" note="An example playlist" version="0.1">
338         ...     <curve note="The first curve" path="../curve/one"/>
339         ...     <curve attr\xb7with\xb7spaces="The second curve&#xA;with endlines" path="../curve/two"/>
340         ... </playlist>
341         ... '''
342         >>> p = FilePlaylist(drivers=[],
343         ...                  path=os.path.join('path', 'to', 'my', 'playlist'))
344         >>> p.from_string(string, identify=False)
345         >>> p._index
346         1
347         >>> p.info
348         {u'note': u'An example playlist'}
349         >>> for curve in p:
350         ...     print curve.path
351         path/to/curve/one
352         path/to/curve/two
353         >>> p[-1].info['attr with spaces']
354         u'The second curve\\nwith endlines'
355         """
356         doc = xml.dom.minidom.parseString(string)
357         self._from_xml_doc(doc, identify=identify)
358
359     def load(self, path=None, identify=True, hooke=None):
360         """Load a playlist from a file.
361         """
362         self.set_path(path)
363         doc = xml.dom.minidom.parse(self.path)
364         self._from_xml_doc(doc, identify=identify)
365         self._digest = self.digest()
366         for curve in self:
367             curve.set_hooke(hooke)
368
369     def save(self, path=None, makedirs=True):
370         """Saves the playlist in a XML file.
371         """
372         self.set_path(path)
373         dirname = os.path.dirname(self.path)
374         if makedirs == True and not os.path.isdir(dirname):
375             os.makedirs(dirname)
376         with open(self.path, 'w') as f:
377             f.write(self.flatten())
378             self._digest = self.digest()