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
33 from .util.itertools import reverse_enumerate
36 class NoteIndexList (list):
37 """A list that keeps track of a "current" item and additional notes.
39 :attr:`index` (i.e. "bookmark") is the index of the currently
40 current curve. Also keep a :class:`dict` of additional information
43 def __init__(self, name=None):
44 super(NoteIndexList, self).__init__()
50 return str(self.__unicode__())
52 def __unicode__(self):
53 return u'<%s %s>' % (self.__class__.__name__, self.name)
58 def _setup_item(self, item):
59 """Perform any required initialization before returning an item.
63 def index(self, value=None, *args, **kwargs):
64 """Extend `list.index`, returning the current index if `value`
69 return super(NoteIndexList, self).index(value, *args, **kwargs)
74 item = self[self._index]
75 self._setup_item(item)
78 def jump(self, index):
82 self._index = index % len(self)
85 self.jump(self._index + 1)
88 self.jump(self._index - 1)
90 def items(self, reverse=False):
91 """Iterate through `self` calling `_setup_item` on each item
96 Updates :attr:`_index` during the iteration so
97 :func:`~hooke.plugin.curve.current_curve_callback` works as
98 expected in :class:`~hooke.command.Command`\s called from
99 :class:`~hooke.plugin.playlist.ApplyCommandStack`. After the
100 iteration completes, :attr:`_index` is restored to its
106 items = reverse_enumerate(self)
108 items = enumerate(self)
111 self._setup_item(item)
115 def filter(self, keeper_fn=lambda item:True, *args, **kwargs):
116 c = copy.deepcopy(self)
117 for item in c.items(reverse=True):
118 if keeper_fn(item, *args, **kwargs) != True:
120 try: # attempt to maintain the same current item
121 c._index = c.index(self.current())
127 class Playlist (NoteIndexList):
128 """A :class:`NoteIndexList` of :class:`hooke.curve.Curve`\s.
130 Keeps a list of :attr:`drivers` for loading curves.
132 def __init__(self, drivers, name=None):
133 super(Playlist, self).__init__(name=name)
134 self.drivers = drivers
135 self._loaded = [] # List of loaded curves, see :meth:`._setup_item`.
136 self._max_loaded = 100 # curves to hold in memory simultaneously.
138 def append_curve_by_path(self, path, info=None, identify=True, hooke=None):
139 path = os.path.normpath(path)
140 c = curve.Curve(path, info=info)
143 c.identify(self.drivers)
147 def _setup_item(self, curve):
148 if curve != None and curve not in self._loaded:
149 if curve not in self:
151 if curve.driver == None:
152 c.identify(self.drivers)
153 if curve.data == None:
155 self._loaded.append(curve)
156 if len(self._loaded) > self._max_loaded:
157 oldest = self._loaded.pop(0)
161 class FilePlaylist (Playlist):
162 """A file-backed :class:`Playlist`.
166 def __init__(self, drivers, name=None, path=None):
167 super(FilePlaylist, self).__init__(drivers, name)
171 self._ignored_keys = [
172 'experiment', # class instance, not very exciting.
175 def set_path(self, path):
177 if not path.endswith('.hkp'):
180 if self.name == None:
181 self.name = os.path.basename(path)
183 def append_curve_by_path(self, path, *args, **kwargs):
184 if self.path != None:
185 path = os.path.join(os.path.dirname(self.path), path)
186 super(FilePlaylist, self).append_curve_by_path(path, *args, **kwargs)
189 return self.digest() == self._digest
192 r"""Compute the sha1 digest of the flattened playlist
198 >>> root_path = os.path.sep + 'path'
199 >>> p = FilePlaylist(drivers=[],
200 ... path=os.path.join(root_path, 'to','playlist'))
201 >>> p.info['note'] = 'An example playlist'
202 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
203 >>> c.info['note'] = 'The first curve'
205 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
206 >>> c.info['note'] = 'The second curve'
209 '\\\x14\x87\x88*q\xf8\xaa\xa7\x84f\x82\xa1S>\xfd3+\xd0o'
211 string = self.flatten()
212 return hashlib.sha1(string).digest()
214 def _clean_key(self, key):
215 """Replace spaces in keys with \\u00B7 (middle dot).
217 This character is deemed unlikely to occur in keys to our
218 playlist and curve info dictionaries, while many keys have
221 \\u00B7 is allowed in XML 1.0 as of the 5th edition. See
222 the `4th edition errata`_ for details.
224 .. _4th edition errata:
225 http://www.w3.org/XML/xml-V10-4e-errata#E09
227 return key.replace(' ', u'\u00B7')
229 def _restore_key(self, key):
230 """Restore keys encoded with :meth:`_clean_key`.
232 return key.replace(u'\u00B7', ' ')
234 def flatten(self, absolute_paths=False):
235 """Create a string representation of the playlist.
237 A playlist is an XML document with the following syntax::
239 <?xml version="1.0" encoding="utf-8"?>
240 <playlist attribute="value">
241 <curve path="/my/file/path/"/ attribute="value" ...>
245 Relative paths are interpreted relative to the location of the
251 >>> root_path = os.path.sep + 'path'
252 >>> p = FilePlaylist(drivers=[],
253 ... path=os.path.join(root_path, 'to','playlist'))
254 >>> p.info['note'] = 'An example playlist'
255 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'one'))
256 >>> c.info['note'] = 'The first curve'
258 >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
259 >>> c.info['attr with spaces'] = 'The second curve\\nwith endlines'
261 >>> def _print(string):
262 ... escaped_string = unicode(string, 'utf-8').encode('unicode escape')
263 ... print escaped_string.replace('\\\\n', '\\n').replace('\\\\t', '\\t'),
264 >>> _print(p.flatten()) # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
265 <?xml version="1.0" encoding="utf-8"?>
266 <playlist index="0" note="An example playlist" version="0.1">
267 <curve note="The first curve" path="curve/one"/>
268 <curve attr\\xb7with\\xb7spaces="The second curve
with endlines" path="curve/two"/>
270 >>> _print(p.flatten(absolute_paths=True)) # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
271 <?xml version="1.0" encoding="utf-8"?>
272 <playlist index="0" note="An example playlist" version="0.1">
273 <curve note="The first curve" path="/path/to/curve/one"/>
274 <curve attr\\xb7with\\xb7spaces="The second curve
with endlines" path="/path/to/curve/two"/>
277 implementation = xml.dom.minidom.getDOMImplementation()
278 # create the document DOM object and the root element
279 doc = implementation.createDocument(None, 'playlist', None)
280 root = doc.documentElement
281 root.setAttribute('version', self.version) # store playlist version
282 root.setAttribute('index', str(self._index))
283 for key,value in self.info.items(): # save info variables
284 if (key in self._ignored_keys
285 or not isinstance(value, types.StringTypes)):
287 root.setAttribute(self._clean_key(key), str(value))
288 for curve in self: # save curves and their attributes
289 curve_element = doc.createElement('curve')
290 root.appendChild(curve_element)
291 path = os.path.abspath(os.path.expanduser(curve.path))
292 if absolute_paths == False:
293 path = os.path.relpath(
297 os.path.expanduser(self.path))))
298 curve_element.setAttribute('path', path)
299 for key,value in curve.info.items():
300 if (key in self._ignored_keys
301 or not isinstance(value, types.StringTypes)):
303 curve_element.setAttribute(self._clean_key(key), str(value))
304 string = doc.toprettyxml(encoding='utf-8')
305 root.unlink() # break circular references for garbage collection
308 def _from_xml_doc(self, doc, identify=True):
309 """Load a playlist from an :class:`xml.dom.minidom.Document`
312 root = doc.documentElement
313 for attribute,value in root.attributes.items():
314 attribute = self._restore_key(attribute)
315 if attribute == 'version':
316 assert value == self.version, \
317 'Cannot read v%s playlist with a v%s reader' \
318 % (value, self.version)
319 elif attribute == 'index':
320 self._index = int(value)
322 self.info[attribute] = value
323 for curve_element in doc.getElementsByTagName('curve'):
324 path = curve_element.getAttribute('path')
325 info = dict([(self._restore_key(key), value)
326 for key,value in curve_element.attributes.items()])
328 self.append_curve_by_path(path, info, identify=identify)
329 self.jump(self._index) # ensure valid index
331 def from_string(self, string, identify=True):
332 u"""Load a playlist from a string.
337 >>> string = '''<?xml version="1.0" encoding="utf-8"?>
338 ... <playlist index="1" note="An example playlist" version="0.1">
339 ... <curve note="The first curve" path="../curve/one"/>
340 ... <curve attr\xb7with\xb7spaces="The second curve
with endlines" path="../curve/two"/>
343 >>> p = FilePlaylist(drivers=[],
344 ... path=os.path.join('path', 'to', 'my', 'playlist'))
345 >>> p.from_string(string, identify=False)
349 {u'note': u'An example playlist'}
354 >>> p[-1].info['attr with spaces']
355 u'The second curve\\nwith endlines'
357 doc = xml.dom.minidom.parseString(string)
358 self._from_xml_doc(doc, identify=identify)
360 def load(self, path=None, identify=True, hooke=None):
361 """Load a playlist from a file.
364 doc = xml.dom.minidom.parse(self.path)
365 self._from_xml_doc(doc, identify=identify)
366 self._digest = self.digest()
368 curve.set_hooke(hooke)
370 def save(self, path=None, makedirs=True):
371 """Saves the playlist in a XML file.
374 dirname = os.path.dirname(self.path) or '.'
375 if makedirs == True and not os.path.isdir(dirname):
377 with open(self.path, 'w') as f:
378 f.write(self.flatten())
379 self._digest = self.digest()