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