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.
29 import xml.dom.minidom
31 from . import curve as curve
32 from .compat import minidom as minidom # dynamically patch xml.sax.minidom
35 class NoteIndexList (list):
36 """A list that keeps track of a "current" item and additional notes.
38 :attr:`index` (i.e. "bookmark") is the index of the currently
39 current curve. Also keep a :class:`dict` of additional information
42 def __init__(self, name=None):
43 super(NoteIndexList, self).__init__()
49 return str(self.__unicode__())
51 def __unicode__(self):
52 return u'<%s %s>' % (self.__class__.__name__, self.name)
57 def _setup_item(self, item):
58 """Perform any required initialization before returning an item.
62 def index(self, value=None, *args, **kwargs):
63 """Extend `list.index`, returning the current index if `value`
68 return super(NoteIndexList, self).index(value, *args, **kwargs)
73 item = self[self._index]
74 self._setup_item(item)
77 def jump(self, index):
81 self._index = index % len(self)
84 self.jump(self._index + 1)
87 self.jump(self._index - 1)
89 def items(self, reverse=False):
90 """Iterate through `self` calling `_setup_item` on each item
95 Updates :attr:`_index` during the iteration so
96 :func:`~hooke.plugin.curve.current_curve_callback` works as
97 expected in :class:`~hooke.command.Command`\s called from
98 :class:`~hooke.plugin.playlist.ApplyCommandStack`. After the
99 iteration completes, :attr:`_index` is restored to its
105 items = reversed(enumerate(self))
107 items = enumerate(self)
110 self._setup_item(item)
114 def filter(self, keeper_fn=lambda item:True, *args, **kwargs):
115 c = copy.deepcopy(self)
116 for item in c.items(reverse=True):
117 if keeper_fn(item, *args, **kwargs) != True:
119 try: # attempt to maintain the same current item
120 c._index = c.index(self.current())
126 class Playlist (NoteIndexList):
127 """A :class:`NoteIndexList` of :class:`hooke.curve.Curve`\s.
129 Keeps a list of :attr:`drivers` for loading curves.
131 def __init__(self, drivers, name=None):
132 super(Playlist, self).__init__(name=name)
133 self.drivers = drivers
134 self._loaded = [] # List of loaded curves, see :meth:`._setup_item`.
135 self._max_loaded = 100 # curves to hold in memory simultaneously.
137 def append_curve_by_path(self, path, info=None, identify=True, hooke=None):
138 path = os.path.normpath(path)
139 c = curve.Curve(path, info=info)
142 c.identify(self.drivers)
146 def _setup_item(self, curve):
147 if curve != None and curve not in self._loaded:
148 if curve not in self:
150 if curve.driver == None:
151 c.identify(self.drivers)
152 if curve.data == None:
154 self._loaded.append(curve)
155 if len(self._loaded) > self._max_loaded:
156 oldest = self._loaded.pop(0)
160 class FilePlaylist (Playlist):
161 """A file-backed :class:`Playlist`.
165 def __init__(self, drivers, name=None, path=None):
166 super(FilePlaylist, self).__init__(drivers, name)
170 self._ignored_keys = [
171 'experiment', # class instance, not very exciting.
174 def set_path(self, path):
176 if not path.endswith('.hkp'):
179 if self.name == None:
180 self.name = os.path.basename(path)
182 def append_curve_by_path(self, path, *args, **kwargs):
183 if self.path != None:
184 path = os.path.join(os.path.dirname(self.path), path)
185 super(FilePlaylist, self).append_curve_by_path(path, *args, **kwargs)
188 return self.digest() == self._digest
191 r"""Compute the sha1 digest of the flattened playlist
197 >>> root_path = os.path.sep + 'path'
198 >>> p = FilePlaylist(drivers=[],
199 ... path=os.path.join(root_path, 'to','playlist'))
200 >>> p.info['note'] = 'An example playlist'
201 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
202 >>> c.info['note'] = 'The first curve'
204 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
205 >>> c.info['note'] = 'The second curve'
208 '\\\x14\x87\x88*q\xf8\xaa\xa7\x84f\x82\xa1S>\xfd3+\xd0o'
210 string = self.flatten()
211 return hashlib.sha1(string).digest()
213 def _clean_key(self, key):
214 """Replace spaces in keys with \\u00B7 (middle dot).
216 This character is deemed unlikely to occur in keys to our
217 playlist and curve info dictionaries, while many keys have
220 \\u00B7 is allowed in XML 1.0 as of the 5th edition. See
221 the `4th edition errata`_ for details.
223 .. _4th edition errata:
224 http://www.w3.org/XML/xml-V10-4e-errata#E09
226 return key.replace(' ', u'\u00B7')
228 def _restore_key(self, key):
229 """Restore keys encoded with :meth:`_clean_key`.
231 return key.replace(u'\u00B7', ' ')
233 def flatten(self, absolute_paths=False):
234 """Create a string representation of the playlist.
236 A playlist is an XML document with the following syntax::
238 <?xml version="1.0" encoding="utf-8"?>
239 <playlist attribute="value">
240 <curve path="/my/file/path/"/ attribute="value" ...>
244 Relative paths are interpreted relative to the location of the
250 >>> root_path = os.path.sep + 'path'
251 >>> p = FilePlaylist(drivers=[],
252 ... path=os.path.join(root_path, 'to','playlist'))
253 >>> p.info['note'] = 'An example playlist'
254 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
255 >>> c.info['note'] = 'The first curve'
257 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
258 >>> c.info['attr with spaces'] = 'The second curve\\nwith endlines'
260 >>> def _print(string):
261 ... escaped_string = unicode(string, 'utf-8').encode('unicode escape')
262 ... print escaped_string.replace('\\\\n', '\\n').replace('\\\\t', '\\t'),
263 >>> _print(p.flatten()) # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
264 <?xml version="1.0" encoding="utf-8"?>
265 <playlist index="0" note="An example playlist" version="0.1">
266 <curve note="The first curve" path="curve/one"/>
267 <curve attr\\xb7with\\xb7spaces="The second curve
with endlines" path="curve/two"/>
269 >>> _print(p.flatten(absolute_paths=True)) # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
270 <?xml version="1.0" encoding="utf-8"?>
271 <playlist index="0" note="An example playlist" version="0.1">
272 <curve note="The first curve" path="/path/to/curve/one"/>
273 <curve attr\\xb7with\\xb7spaces="The second curve
with endlines" path="/path/to/curve/two"/>
276 implementation = xml.dom.minidom.getDOMImplementation()
277 # create the document DOM object and the root element
278 doc = implementation.createDocument(None, 'playlist', None)
279 root = doc.documentElement
280 root.setAttribute('version', self.version) # store playlist version
281 root.setAttribute('index', str(self._index))
282 for key,value in self.info.items(): # save info variables
283 if (key in self._ignored_keys
284 or not isinstance(value, types.StringTypes)):
286 root.setAttribute(self._clean_key(key), str(value))
287 for curve in self: # save curves and their attributes
288 curve_element = doc.createElement('curve')
289 root.appendChild(curve_element)
290 path = os.path.abspath(os.path.expanduser(curve.path))
291 if absolute_paths == False:
292 path = os.path.relpath(
296 os.path.expanduser(self.path))))
297 curve_element.setAttribute('path', path)
298 for key,value in curve.info.items():
299 if (key in self._ignored_keys
300 or not isinstance(value, types.StringTypes)):
302 curve_element.setAttribute(self._clean_key(key), str(value))
303 string = doc.toprettyxml(encoding='utf-8')
304 root.unlink() # break circular references for garbage collection
307 def _from_xml_doc(self, doc, identify=True):
308 """Load a playlist from an :class:`xml.dom.minidom.Document`
311 root = doc.documentElement
312 for attribute,value in root.attributes.items():
313 attribute = self._restore_key(attribute)
314 if attribute == 'version':
315 assert value == self.version, \
316 'Cannot read v%s playlist with a v%s reader' \
317 % (value, self.version)
318 elif attribute == 'index':
319 self._index = int(value)
321 self.info[attribute] = value
322 for curve_element in doc.getElementsByTagName('curve'):
323 path = curve_element.getAttribute('path')
324 info = dict([(self._restore_key(key), value)
325 for key,value in curve_element.attributes.items()])
327 self.append_curve_by_path(path, info, identify=identify)
328 self.jump(self._index) # ensure valid index
330 def from_string(self, string, identify=True):
331 u"""Load a playlist from a string.
336 >>> string = '''<?xml version="1.0" encoding="utf-8"?>
337 ... <playlist index="1" note="An example playlist" version="0.1">
338 ... <curve note="The first curve" path="../curve/one"/>
339 ... <curve attr\xb7with\xb7spaces="The second curve
with endlines" path="../curve/two"/>
342 >>> p = FilePlaylist(drivers=[],
343 ... path=os.path.join('path', 'to', 'my', 'playlist'))
344 >>> p.from_string(string, identify=False)
348 {u'note': u'An example playlist'}
353 >>> p[-1].info['attr with spaces']
354 u'The second curve\\nwith endlines'
356 doc = xml.dom.minidom.parseString(string)
357 self._from_xml_doc(doc, identify=identify)
359 def load(self, path=None, identify=True, hooke=None):
360 """Load a playlist from a file.
363 doc = xml.dom.minidom.parse(self.path)
364 self._from_xml_doc(doc, identify=identify)
365 self._digest = self.digest()
367 curve.set_hooke(hooke)
369 def save(self, path=None, makedirs=True):
370 """Saves the playlist in a XML file.
373 dirname = os.path.dirname(self.path) or '.'
374 if makedirs == True and not os.path.isdir(dirname):
376 with open(self.path, 'w') as f:
377 f.write(self.flatten())
378 self._digest = self.digest()