Replace .config rather than reconstructing plugins, drivers, and UIs.
[hooke.git] / hooke / playlist.py
index ca38b4e923936c77ff57f807564613a75cae0979..dfec6d72c28c65b3888f6b4d5265b43fee523d73 100644 (file)
@@ -2,15 +2,15 @@
 #
 # This file is part of Hooke.
 #
-# Hooke is free software: you can redistribute it and/or
-# modify it under the terms of the GNU Lesser General Public
-# License as published by the Free Software Foundation, either
-# version 3 of the License, or (at your option) any later version.
+# Hooke is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
 #
-# Hooke is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Lesser General Public License for more details.
+# Hooke is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General
+# Public License for more details.
 #
 # You should have received a copy of the GNU Lesser General Public
 # License along with Hooke.  If not, see
 import copy
 import hashlib
 import os.path
+import types
 import xml.dom.minidom
 
 from . import curve as curve
+from .compat import minidom as minidom  # dynamically patch xml.sax.minidom
 
 
 class NoteIndexList (list):
@@ -43,12 +45,33 @@ class NoteIndexList (list):
         self._index = 0
 
     def __str__(self):
-        return '<%s %s>' % (self.__class__.__name__, self.name)
+        return str(self.__unicode__())
+
+    def __unicode__(self):
+        return u'<%s %s>' % (self.__class__.__name__, self.name)
+
+    def __repr__(self):
+        return self.__str__()
+
+    def _setup_item(self, item):
+        """Perform any required initialization before returning an item.
+        """
+        pass
+
+    def index(self, value=None, *args, **kwargs):
+        """Extend `list.index`, returning the current index if `value`
+        is `None`.
+        """
+        if value == None:
+            return self._index
+        return super(NoteIndexList, self).index(value, *args, **kwargs)
 
     def current(self):
         if len(self) == 0:
             return None
-        return self[self._index]
+        item = self[self._index]
+        self._setup_item(item)
+        return item
 
     def jump(self, index):
         if len(self) == 0:
@@ -62,10 +85,35 @@ class NoteIndexList (list):
     def previous(self):
         self.jump(self._index - 1)
 
-    def filter(self, keeper_fn=lambda item:True):
+    def items(self, reverse=False):
+        """Iterate through `self` calling `_setup_item` on each item
+        before yielding.
+
+        Notes
+        -----
+        Updates :attr:`_index` during the iteration so
+        :func:`~hooke.plugin.curve.current_curve_callback` works as
+        expected in :class:`~hooke.command.Command`\s called from
+        :class:`~hooke.plugin.playlist.ApplyCommandStack`.  After the
+        iteration completes, :attr:`_index` is restored to its
+        original value.
+        """
+        index = self._index
+        items = self
+        if reverse == True:
+            items = reversed(enumerate(self))
+        else:
+            items = enumerate(self)
+        for i,item in items:
+            self._index = i
+            self._setup_item(item)
+            yield item
+        self._index = index
+
+    def filter(self, keeper_fn=lambda item:True, *args, **kwargs):
         c = copy.deepcopy(self)
-        for item in reversed(c):
-            if keeper_fn(item) != True:
+        for item in c.items(reverse=True):
+            if keeper_fn(item, *args, **kwargs) != True:
                 c.remove(item)
         try: # attempt to maintain the same current item
             c._index = c.index(self.current())
@@ -73,6 +121,7 @@ class NoteIndexList (list):
             c._index = 0
         return c
 
+
 class Playlist (NoteIndexList):
     """A :class:`NoteIndexList` of :class:`hooke.curve.Curve`\s.
 
@@ -81,25 +130,19 @@ class Playlist (NoteIndexList):
     def __init__(self, drivers, name=None):
         super(Playlist, self).__init__(name=name)
         self.drivers = drivers
-        self._loaded = [] # List of loaded curves, see :meth:`._load`.
+        self._loaded = [] # List of loaded curves, see :meth:`._setup_item`.
         self._max_loaded = 100 # curves to hold in memory simultaneously.
 
-    def append_curve_by_path(self, path, info=None, identify=True):
-        if self.path != None:
-            path = os.path.join(os.path.dirname(self.path), path)
+    def append_curve_by_path(self, path, info=None, identify=True, hooke=None):
         path = os.path.normpath(path)
         c = curve.Curve(path, info=info)
+        c.set_hooke(hooke)
         if identify == True:
             c.identify(self.drivers)
         self.append(c)
         return c
 
-    def current(self):
-        curve = super(Playlist, self).current()
-        self._load(curve)
-        return curve
-
-    def _load(self, curve):
+    def _setup_item(self, curve):
         if curve != None and curve not in self._loaded:
             if curve not in self:
                 self.append(curve)
@@ -112,13 +155,18 @@ class Playlist (NoteIndexList):
                 oldest = self._loaded.pop(0)
                 oldest.unload()
 
+
 class FilePlaylist (Playlist):
     version = '0.1'
 
     def __init__(self, drivers, name=None, path=None):
         super(FilePlaylist, self).__init__(drivers, name)
+        self.path = None
         self.set_path(path)
         self._digest = None
+        self._ignored_keys = [
+            'experiment',  # class instance, not very exciting.
+            ]
 
     def set_path(self, path):
         if path != None:
@@ -128,6 +176,11 @@ class FilePlaylist (Playlist):
             if self.name == None:
                 self.name = os.path.basename(path)
 
+    def append_curve_by_path(self, path, *args, **kwargs):
+        if self.path != None:
+            path = os.path.join(os.path.dirname(self.path), path)
+        super(FilePlaylist, self).append_curve_by_path(path, *args, **kwargs)
+
     def is_saved(self):
         return self.digest() == self._digest
 
@@ -149,11 +202,31 @@ class FilePlaylist (Playlist):
         >>> c.info['note'] = 'The second curve'
         >>> p.append(c)
         >>> p.digest()
-        "\xa1\x99\x8a\x99\xed\xad\x13'\xa7w\x12\x00\x07Z\xb3\xd0zN\xa2\xe1"
+        '\\\x14\x87\x88*q\xf8\xaa\xa7\x84f\x82\xa1S>\xfd3+\xd0o'
         """
         string = self.flatten()
         return hashlib.sha1(string).digest()
 
+    def _clean_key(self, key):
+        """Replace spaces in keys with \\u00B7 (middle dot).
+
+        This character is deemed unlikely to occur in keys to our
+        playlist and curve info dictionaries, while many keys have
+        spaces in them.
+
+        \\u00B7 is allowed in XML 1.0 as of the 5th edition.  See
+        the `4th edition errata`_ for details.
+
+        .. _4th edition errata:
+          http://www.w3.org/XML/xml-V10-4e-errata#E09
+        """
+        return key.replace(' ', u'\u00B7')
+
+    def _restore_key(self, key):
+        """Restore keys encoded with :meth:`_clean_key`.
+        """
+        return key.replace(u'\u00B7', ' ')
+
     def flatten(self, absolute_paths=False):
         """Create a string representation of the playlist.
 
@@ -179,22 +252,23 @@ class FilePlaylist (Playlist):
         >>> c.info['note'] = 'The first curve'
         >>> p.append(c)
         >>> c = curve.Curve(os.path.join(root_path, 'to', 'curve', 'two'))
-        >>> c.info['note'] = 'The second curve'
+        >>> c.info['attr with spaces'] = 'The second curve\\nwith endlines'
         >>> p.append(c)
-        >>> print p.flatten() # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
+        >>> def _print(string):
+        ...     escaped_string = unicode(string, 'utf-8').encode('unicode escape')
+        ...     print escaped_string.replace('\\\\n', '\\n').replace('\\\\t', '\\t'),
+        >>> _print(p.flatten())  # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
         <?xml version="1.0" encoding="utf-8"?>
         <playlist index="0" note="An example playlist" version="0.1">
-            <curve note="The first curve" path="../curve/one"/>
-            <curve note="The second curve" path="../curve/two"/>
+           <curve note="The first curve" path="curve/one"/>
+           <curve attr\\xb7with\\xb7spaces="The second curve&#xA;with endlines" path="curve/two"/>
         </playlist>
-        <BLANKLINE>
-        >>> print p.flatten(absolute_paths=True) # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
+        >>> _print(p.flatten(absolute_paths=True))  # doctest: +NORMALIZE_WHITESPACE +REPORT_UDIFF
         <?xml version="1.0" encoding="utf-8"?>
         <playlist index="0" note="An example playlist" version="0.1">
-            <curve note="The first curve" path="/path/to/curve/one"/>
-            <curve note="The second curve" path="/path/to/curve/two"/>
+           <curve note="The first curve" path="/path/to/curve/one"/>
+           <curve attr\\xb7with\\xb7spaces="The second curve&#xA;with endlines" path="/path/to/curve/two"/>
         </playlist>
-        <BLANKLINE>
         """
         implementation = xml.dom.minidom.getDOMImplementation()
         # create the document DOM object and the root element
@@ -203,7 +277,10 @@ class FilePlaylist (Playlist):
         root.setAttribute('version', self.version) # store playlist version
         root.setAttribute('index', str(self._index))
         for key,value in self.info.items(): # save info variables
-            root.setAttribute(key, str(value))
+            if (key in self._ignored_keys
+                or not isinstance(value, types.StringTypes)):
+                continue
+            root.setAttribute(self._clean_key(key), str(value))
         for curve in self: # save curves and their attributes
             curve_element = doc.createElement('curve')
             root.appendChild(curve_element)
@@ -211,10 +288,15 @@ class FilePlaylist (Playlist):
             if absolute_paths == False:
                 path = os.path.relpath(
                     path,
-                    os.path.abspath(os.path.expanduser(self.path)))
+                    os.path.dirname(
+                        os.path.abspath(
+                            os.path.expanduser(self.path))))
             curve_element.setAttribute('path', path)
             for key,value in curve.info.items():
-                curve_element.setAttribute(key, str(value))
+                if (key in self._ignored_keys
+                    or not isinstance(value, types.StringTypes)):
+                    continue
+                curve_element.setAttribute(self._clean_key(key), str(value))
         string = doc.toprettyxml(encoding='utf-8')
         root.unlink() # break circular references for garbage collection
         return string
@@ -225,6 +307,7 @@ class FilePlaylist (Playlist):
         """
         root = doc.documentElement
         for attribute,value in root.attributes.items():
+            attribute = self._restore_key(attribute)
             if attribute == 'version':
                 assert value == self.version, \
                     'Cannot read v%s playlist with a v%s reader' \
@@ -235,13 +318,14 @@ class FilePlaylist (Playlist):
                 self.info[attribute] = value
         for curve_element in doc.getElementsByTagName('curve'):
             path = curve_element.getAttribute('path')
-            info = dict(curve_element.attributes.items())
+            info = dict([(self._restore_key(key), value)
+                         for key,value in curve_element.attributes.items()])
             info.pop('path')
             self.append_curve_by_path(path, info, identify=identify)
         self.jump(self._index) # ensure valid index
 
     def from_string(self, string, identify=True):
-        """Load a playlist from a string.
+        u"""Load a playlist from a string.
 
         Examples
         --------
@@ -249,7 +333,7 @@ class FilePlaylist (Playlist):
         >>> string = '''<?xml version="1.0" encoding="utf-8"?>
         ... <playlist index="1" note="An example playlist" version="0.1">
         ...     <curve note="The first curve" path="../curve/one"/>
-        ...     <curve note="The second curve" path="../curve/two"/>
+        ...     <curve attr\xb7with\xb7spaces="The second curve&#xA;with endlines" path="../curve/two"/>
         ... </playlist>
         ... '''
         >>> p = FilePlaylist(drivers=[],
@@ -263,22 +347,26 @@ class FilePlaylist (Playlist):
         ...     print curve.path
         path/to/curve/one
         path/to/curve/two
+        >>> p[-1].info['attr with spaces']
+        u'The second curve\\nwith endlines'
         """
         doc = xml.dom.minidom.parseString(string)
         self._from_xml_doc(doc, identify=identify)
 
-    def load(self, path=None, identify=True):
+    def load(self, path=None, identify=True, hooke=None):
         """Load a playlist from a file.
         """
         self.set_path(path)
         doc = xml.dom.minidom.parse(self.path)
         self._from_xml_doc(doc, identify=identify)
         self._digest = self.digest()
-
+        for curve in self:
+            curve.set_hooke(hooke)
+        
     def save(self, path=None):
         """Saves the playlist in a XML file.
         """
         self.set_path(path)
-        f = file(self.path, 'w')
-        f.write(self.flatten())
-        f.close()
+        with open(self.path, 'w') as f:
+            f.write(self.flatten())
+            self._digest = self.digest()