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