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 Updates :attr:`_index` during the iteration so
95 :func:`~hooke.plugin.curve.current_curve_callback` works as
96 expected in :class:`~hooke.command.Command`\s called from
97 :class:`~hooke.plugin.playlist.ApplyCommandStack`. After the
98 iteration completes, :attr:`_index` is restored to its
104 items = reversed(enumerate(self))
106 items = enumerate(self)
109 self._setup_item(item)
113 def filter(self, keeper_fn=lambda item:True, *args, **kwargs):
114 c = copy.deepcopy(self)
115 for item in c.items(reverse=True):
116 if keeper_fn(item, *args, **kwargs) != True:
118 try: # attempt to maintain the same current item
119 c._index = c.index(self.current())
125 class Playlist (NoteIndexList):
126 """A :class:`NoteIndexList` of :class:`hooke.curve.Curve`\s.
128 Keeps a list of :attr:`drivers` for loading curves.
130 def __init__(self, drivers, name=None):
131 super(Playlist, self).__init__(name=name)
132 self.drivers = drivers
133 self._loaded = [] # List of loaded curves, see :meth:`._setup_item`.
134 self._max_loaded = 100 # curves to hold in memory simultaneously.
136 def append_curve_by_path(self, path, info=None, identify=True, hooke=None):
137 path = os.path.normpath(path)
138 c = curve.Curve(path, info=info)
141 c.identify(self.drivers)
145 def _setup_item(self, curve):
146 if curve != None and curve not in self._loaded:
147 if curve not in self:
149 if curve.driver == None:
150 c.identify(self.drivers)
151 if curve.data == None:
153 self._loaded.append(curve)
154 if len(self._loaded) > self._max_loaded:
155 oldest = self._loaded.pop(0)
159 class FilePlaylist (Playlist):
162 def __init__(self, drivers, name=None, path=None):
163 super(FilePlaylist, self).__init__(drivers, name)
167 self._ignored_keys = [
168 'experiment', # class instance, not very exciting.
171 def set_path(self, path):
173 if not path.endswith('.hkp'):
176 if self.name == None:
177 self.name = os.path.basename(path)
179 def append_curve_by_path(self, path, *args, **kwargs):
180 if self.path != None:
181 path = os.path.join(os.path.dirname(self.path), path)
182 super(FilePlaylist, self).append_curve_by_path(path, *args, **kwargs)
185 return self.digest() == self._digest
188 r"""Compute the sha1 digest of the flattened playlist
194 >>> root_path = os.path.sep + 'path'
195 >>> p = FilePlaylist(drivers=[],
196 ... path=os.path.join(root_path, 'to','playlist'))
197 >>> p.info['note'] = 'An example playlist'
198 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
199 >>> c.info['note'] = 'The first curve'
201 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
202 >>> c.info['note'] = 'The second curve'
205 '\\\x14\x87\x88*q\xf8\xaa\xa7\x84f\x82\xa1S>\xfd3+\xd0o'
207 string = self.flatten()
208 return hashlib.sha1(string).digest()
210 def _clean_key(self, key):
211 """Replace spaces in keys with \\u00B7 (middle dot).
213 This character is deemed unlikely to occur in keys to our
214 playlist and curve info dictionaries, while many keys have
217 \\u00B7 is allowed in XML 1.0 as of the 5th edition. See
218 the `4th edition errata`_ for details.
220 .. _4th edition errata:
221 http://www.w3.org/XML/xml-V10-4e-errata#E09
223 return key.replace(' ', u'\u00B7')
225 def _restore_key(self, key):
226 """Restore keys encoded with :meth:`_clean_key`.
228 return key.replace(u'\u00B7', ' ')
230 def flatten(self, absolute_paths=False):
231 """Create a string representation of the playlist.
233 A playlist is an XML document with the following syntax::
235 <?xml version="1.0" encoding="utf-8"?>
236 <playlist attribute="value">
237 <curve path="/my/file/path/"/ attribute="value" ...>
241 Relative paths are interpreted relative to the location of the
247 >>> root_path = os.path.sep + 'path'
248 >>> p = FilePlaylist(drivers=[],
249 ... path=os.path.join(root_path, 'to','playlist'))
250 >>> p.info['note'] = 'An example playlist'
251 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
252 >>> c.info['note'] = 'The first curve'
254 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
255 >>> c.info['attr with spaces'] = 'The second curve\\nwith endlines'
257 >>> def _print(string):
258 ... escaped_string = unicode(string, 'utf-8').encode('unicode escape')
259 ... print escaped_string.replace('\\\\n', '\\n').replace('\\\\t', '\\t'),
260 >>> _print(p.flatten()) # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
261 <?xml version="1.0" encoding="utf-8"?>
262 <playlist index="0" note="An example playlist" version="0.1">
263 <curve note="The first curve" path="curve/one"/>
264 <curve attr\\xb7with\\xb7spaces="The second curve
with endlines" path="curve/two"/>
266 >>> _print(p.flatten(absolute_paths=True)) # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
267 <?xml version="1.0" encoding="utf-8"?>
268 <playlist index="0" note="An example playlist" version="0.1">
269 <curve note="The first curve" path="/path/to/curve/one"/>
270 <curve attr\\xb7with\\xb7spaces="The second curve
with endlines" path="/path/to/curve/two"/>
273 implementation = xml.dom.minidom.getDOMImplementation()
274 # create the document DOM object and the root element
275 doc = implementation.createDocument(None, 'playlist', None)
276 root = doc.documentElement
277 root.setAttribute('version', self.version) # store playlist version
278 root.setAttribute('index', str(self._index))
279 for key,value in self.info.items(): # save info variables
280 if (key in self._ignored_keys
281 or not isinstance(value, types.StringTypes)):
283 root.setAttribute(self._clean_key(key), str(value))
284 for curve in self: # save curves and their attributes
285 curve_element = doc.createElement('curve')
286 root.appendChild(curve_element)
287 path = os.path.abspath(os.path.expanduser(curve.path))
288 if absolute_paths == False:
289 path = os.path.relpath(
293 os.path.expanduser(self.path))))
294 curve_element.setAttribute('path', path)
295 for key,value in curve.info.items():
296 if (key in self._ignored_keys
297 or not isinstance(value, types.StringTypes)):
299 curve_element.setAttribute(self._clean_key(key), str(value))
300 string = doc.toprettyxml(encoding='utf-8')
301 root.unlink() # break circular references for garbage collection
304 def _from_xml_doc(self, doc, identify=True):
305 """Load a playlist from an :class:`xml.dom.minidom.Document`
308 root = doc.documentElement
309 for attribute,value in root.attributes.items():
310 attribute = self._restore_key(attribute)
311 if attribute == 'version':
312 assert value == self.version, \
313 'Cannot read v%s playlist with a v%s reader' \
314 % (value, self.version)
315 elif attribute == 'index':
316 self._index = int(value)
318 self.info[attribute] = value
319 for curve_element in doc.getElementsByTagName('curve'):
320 path = curve_element.getAttribute('path')
321 info = dict([(self._restore_key(key), value)
322 for key,value in curve_element.attributes.items()])
324 self.append_curve_by_path(path, info, identify=identify)
325 self.jump(self._index) # ensure valid index
327 def from_string(self, string, identify=True):
328 u"""Load a playlist from a string.
333 >>> string = '''<?xml version="1.0" encoding="utf-8"?>
334 ... <playlist index="1" note="An example playlist" version="0.1">
335 ... <curve note="The first curve" path="../curve/one"/>
336 ... <curve attr\xb7with\xb7spaces="The second curve
with endlines" path="../curve/two"/>
339 >>> p = FilePlaylist(drivers=[],
340 ... path=os.path.join('path', 'to', 'my', 'playlist'))
341 >>> p.from_string(string, identify=False)
345 {u'note': u'An example playlist'}
350 >>> p[-1].info['attr with spaces']
351 u'The second curve\\nwith endlines'
353 doc = xml.dom.minidom.parseString(string)
354 self._from_xml_doc(doc, identify=identify)
356 def load(self, path=None, identify=True, hooke=None):
357 """Load a playlist from a file.
360 doc = xml.dom.minidom.parse(self.path)
361 self._from_xml_doc(doc, identify=identify)
362 self._digest = self.digest()
364 curve.set_hooke(hooke)
366 def save(self, path=None):
367 """Saves the playlist in a XML file.
370 with open(self.path, 'w') as f:
371 f.write(self.flatten())
372 self._digest = self.digest()