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