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