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