1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
3 # This file is part of Hooke.
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.
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.
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/>.
19 """The `playlist` module provides a :class:`Playlist` and its subclass
20 :class:`FilePlaylist` for manipulating lists of
21 :class:`hooke.curve.Curve`\s.
28 import xml.dom.minidom
30 from . import curve as curve
31 from .compat import minidom as minidom # dynamically patch xml.sax.minidom
34 class NoteIndexList (list):
35 """A list that keeps track of a "current" item and additional notes.
37 :attr:`index` (i.e. "bookmark") is the index of the currently
38 current curve. Also keep a :class:`dict` of additional information
41 def __init__(self, name=None):
42 super(NoteIndexList, self).__init__()
48 return str(self.__unicode__())
50 def __unicode__(self):
51 return u'<%s %s>' % (self.__class__.__name__, self.name)
56 def _setup_item(self, item):
57 """Perform any required initialization before returning an item.
61 def index(self, value=None, *args, **kwargs):
62 """Extend `list.index`, returning the current index if `value`
67 return super(NoteIndexList, self).index(value, *args, **kwargs)
72 item = self[self._index]
73 self._setup_item(item)
76 def jump(self, index):
80 self._index = index % len(self)
83 self.jump(self._index + 1)
86 self.jump(self._index - 1)
88 def items(self, reverse=False):
89 """Iterate through `self` calling `_setup_item` on each item
94 items = reversed(self)
96 self._setup_item(item)
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:
104 try: # attempt to maintain the same current item
105 c._index = c.index(self.current())
111 class Playlist (NoteIndexList):
112 """A :class:`NoteIndexList` of :class:`hooke.curve.Curve`\s.
114 Keeps a list of :attr:`drivers` for loading curves.
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 self._curve_load_args = ()
122 self._curve_load_kwargs = {}
124 def append_curve_by_path(self, path, info=None, identify=True):
125 path = os.path.normpath(path)
126 c = curve.Curve(path, info=info)
128 c.identify(self.drivers)
132 def set_curve_load_args(self, *args, **kwargs):
133 self._curve_load_args = args
134 self._curve_load_kwargs = kwargs
136 def _setup_item(self, curve):
137 if curve != None and curve not in self._loaded:
138 if curve not in self:
140 if curve.driver == None:
141 c.identify(self.drivers)
142 if curve.data == None:
143 curve.load(*self._curve_load_args, **self._curve_load_kwargs)
144 self._loaded.append(curve)
145 if len(self._loaded) > self._max_loaded:
146 oldest = self._loaded.pop(0)
150 class FilePlaylist (Playlist):
153 def __init__(self, drivers, name=None, path=None):
154 super(FilePlaylist, self).__init__(drivers, name)
158 self._ignored_keys = [
159 'experiment', # class instance, not very exciting.
162 def set_path(self, path):
164 if not path.endswith('.hkp'):
167 if self.name == None:
168 self.name = os.path.basename(path)
170 def append_curve_by_path(self, path, *args, **kwargs):
171 if self.path != None:
172 path = os.path.join(os.path.dirname(self.path), path)
173 super(FilePlaylist, self).append_curve_by_path(path, *args, **kwargs)
176 return self.digest() == self._digest
179 r"""Compute the sha1 digest of the flattened playlist
185 >>> root_path = os.path.sep + 'path'
186 >>> p = FilePlaylist(drivers=[],
187 ... path=os.path.join(root_path, 'to','playlist'))
188 >>> p.info['note'] = 'An example playlist'
189 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
190 >>> c.info['note'] = 'The first curve'
192 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
193 >>> c.info['note'] = 'The second curve'
196 '\\\x14\x87\x88*q\xf8\xaa\xa7\x84f\x82\xa1S>\xfd3+\xd0o'
198 string = self.flatten()
199 return hashlib.sha1(string).digest()
201 def _clean_key(self, key):
202 """Replace spaces in keys with \\u00B7 (middle dot).
204 This character is deemed unlikely to occur in keys to our
205 playlist and curve info dictionaries, while many keys have
208 \\u00B7 is allowed in XML 1.0 as of the 5th edition. See
209 the `4th edition errata`_ for details.
211 .. _4th edition errata:
212 http://www.w3.org/XML/xml-V10-4e-errata#E09
214 return key.replace(' ', u'\u00B7')
216 def _restore_key(self, key):
217 """Restore keys encoded with :meth:`_clean_key`.
219 return key.replace(u'\u00B7', ' ')
221 def flatten(self, absolute_paths=False):
222 """Create a string representation of the playlist.
224 A playlist is an XML document with the following syntax::
226 <?xml version="1.0" encoding="utf-8"?>
227 <playlist attribute="value">
228 <curve path="/my/file/path/"/ attribute="value" ...>
232 Relative paths are interpreted relative to the location of the
238 >>> root_path = os.path.sep + 'path'
239 >>> p = FilePlaylist(drivers=[],
240 ... path=os.path.join(root_path, 'to','playlist'))
241 >>> p.info['note'] = 'An example playlist'
242 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
243 >>> c.info['note'] = 'The first curve'
245 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
246 >>> c.info['attr with spaces'] = 'The second curve\\nwith endlines'
248 >>> def _print(string):
249 ... escaped_string = unicode(string, 'utf-8').encode('unicode escape')
250 ... print escaped_string.replace('\\\\n', '\\n').replace('\\\\t', '\\t'),
251 >>> _print(p.flatten()) # 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="curve/one"/>
255 <curve attr\\xb7with\\xb7spaces="The second curve
with endlines" path="curve/two"/>
257 >>> _print(p.flatten(absolute_paths=True)) # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
258 <?xml version="1.0" encoding="utf-8"?>
259 <playlist index="0" note="An example playlist" version="0.1">
260 <curve note="The first curve" path="/path/to/curve/one"/>
261 <curve attr\\xb7with\\xb7spaces="The second curve
with endlines" path="/path/to/curve/two"/>
264 implementation = xml.dom.minidom.getDOMImplementation()
265 # create the document DOM object and the root element
266 doc = implementation.createDocument(None, 'playlist', None)
267 root = doc.documentElement
268 root.setAttribute('version', self.version) # store playlist version
269 root.setAttribute('index', str(self._index))
270 for key,value in self.info.items(): # save info variables
271 if (key in self._ignored_keys
272 or not isinstance(value, types.StringTypes)):
274 root.setAttribute(self._clean_key(key), str(value))
275 for curve in self: # save curves and their attributes
276 curve_element = doc.createElement('curve')
277 root.appendChild(curve_element)
278 path = os.path.abspath(os.path.expanduser(curve.path))
279 if absolute_paths == False:
280 path = os.path.relpath(
284 os.path.expanduser(self.path))))
285 curve_element.setAttribute('path', path)
286 for key,value in curve.info.items():
287 if (key in self._ignored_keys
288 or not isinstance(value, types.StringTypes)):
290 curve_element.setAttribute(self._clean_key(key), str(value))
291 string = doc.toprettyxml(encoding='utf-8')
292 root.unlink() # break circular references for garbage collection
295 def _from_xml_doc(self, doc, identify=True):
296 """Load a playlist from an :class:`xml.dom.minidom.Document`
299 root = doc.documentElement
300 for attribute,value in root.attributes.items():
301 attribute = self._restore_key(attribute)
302 if attribute == 'version':
303 assert value == self.version, \
304 'Cannot read v%s playlist with a v%s reader' \
305 % (value, self.version)
306 elif attribute == 'index':
307 self._index = int(value)
309 self.info[attribute] = value
310 for curve_element in doc.getElementsByTagName('curve'):
311 path = curve_element.getAttribute('path')
312 info = dict([(self._restore_key(key), value)
313 for key,value in curve_element.attributes.items()])
315 self.append_curve_by_path(path, info, identify=identify)
316 self.jump(self._index) # ensure valid index
318 def from_string(self, string, identify=True):
319 u"""Load a playlist from a string.
324 >>> string = '''<?xml version="1.0" encoding="utf-8"?>
325 ... <playlist index="1" note="An example playlist" version="0.1">
326 ... <curve note="The first curve" path="../curve/one"/>
327 ... <curve attr\xb7with\xb7spaces="The second curve
with endlines" path="../curve/two"/>
330 >>> p = FilePlaylist(drivers=[],
331 ... path=os.path.join('path', 'to', 'my', 'playlist'))
332 >>> p.from_string(string, identify=False)
336 {u'note': u'An example playlist'}
341 >>> p[-1].info['attr with spaces']
342 u'The second curve\\nwith endlines'
344 doc = xml.dom.minidom.parseString(string)
345 self._from_xml_doc(doc, identify=identify)
347 def load(self, path=None, identify=True):
348 """Load a playlist from a file.
351 doc = xml.dom.minidom.parse(self.path)
352 self._from_xml_doc(doc, identify=identify)
353 self._digest = self.digest()
355 def save(self, path=None):
356 """Saves the playlist in a XML file.
359 with open(self.path, 'w') as f:
360 f.write(self.flatten())
361 self._digest = self.digest()