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