Allow flattening of path-less playlists for command.
[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.ApplyCommand`.  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         if self.path == None:
289             base_path = os.getcwd()
290         else:
291             base_path = os.path.abspath(
292                 os.path.expanduser(self.path))
293         for curve in self: # save curves and their attributes
294             curve_element = doc.createElement('curve')
295             root.appendChild(curve_element)
296             path = os.path.abspath(os.path.expanduser(curve.path))
297             if absolute_paths == False:
298                 path = os.path.relpath(
299                     path,
300                     os.path.dirname(base_path))
301             curve_element.setAttribute('path', path)
302             for key,value in curve.info.items():
303                 if (key in self._ignored_keys
304                     or not isinstance(value, types.StringTypes)):
305                     continue
306                 curve_element.setAttribute(self._clean_key(key), str(value))
307         string = doc.toprettyxml(encoding='utf-8')
308         root.unlink() # break circular references for garbage collection
309         return string
310
311     def _from_xml_doc(self, doc, identify=True):
312         """Load a playlist from an :class:`xml.dom.minidom.Document`
313         instance.
314         """
315         root = doc.documentElement
316         for attribute,value in root.attributes.items():
317             attribute = self._restore_key(attribute)
318             if attribute == 'version':
319                 assert value == self.version, \
320                     'Cannot read v%s playlist with a v%s reader' \
321                     % (value, self.version)
322             elif attribute == 'index':
323                 self._index = int(value)
324             else:
325                 self.info[attribute] = value
326         for curve_element in doc.getElementsByTagName('curve'):
327             path = curve_element.getAttribute('path')
328             info = dict([(self._restore_key(key), value)
329                          for key,value in curve_element.attributes.items()])
330             info.pop('path')
331             self.append_curve_by_path(path, info, identify=identify)
332         self.jump(self._index) # ensure valid index
333
334     def from_string(self, string, identify=True):
335         u"""Load a playlist from a string.
336
337         Examples
338         --------
339
340         >>> string = '''<?xml version="1.0" encoding="utf-8"?>
341         ... <playlist index="1" note="An example playlist" version="0.1">
342         ...     <curve note="The first curve" path="../curve/one"/>
343         ...     <curve attr\xb7with\xb7spaces="The second curve&#xA;with endlines" path="../curve/two"/>
344         ... </playlist>
345         ... '''
346         >>> p = FilePlaylist(drivers=[],
347         ...                  path=os.path.join('path', 'to', 'my', 'playlist'))
348         >>> p.from_string(string, identify=False)
349         >>> p._index
350         1
351         >>> p.info
352         {u'note': u'An example playlist'}
353         >>> for curve in p:
354         ...     print curve.path
355         path/to/curve/one
356         path/to/curve/two
357         >>> p[-1].info['attr with spaces']
358         u'The second curve\\nwith endlines'
359         """
360         doc = xml.dom.minidom.parseString(string)
361         self._from_xml_doc(doc, identify=identify)
362
363     def load(self, path=None, identify=True, hooke=None):
364         """Load a playlist from a file.
365         """
366         self.set_path(path)
367         doc = xml.dom.minidom.parse(self.path)
368         self._from_xml_doc(doc, identify=identify)
369         self._digest = self.digest()
370         for curve in self:
371             curve.set_hooke(hooke)
372
373     def save(self, path=None, makedirs=True):
374         """Saves the playlist in a XML file.
375         """
376         self.set_path(path)
377         dirname = os.path.dirname(self.path) or '.'
378         if makedirs == True and not os.path.isdir(dirname):
379             os.makedirs(dirname)
380         with open(self.path, 'w') as f:
381             f.write(self.flatten())
382             self._digest = self.digest()