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