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