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