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