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