-# Copyright\r
-\r
-"""Playlist panel for Hooke.\r
-\r
-Provides a nice GUI interface to the\r
-:class:`~hooke.plugin.playlist.PlaylistPlugin`.\r
-"""\r
-\r
-import types\r
-\r
-import wx\r
-\r
-from ....util.callback import callback, in_callback\r
-\r
-\r
-class Menu (wx.Menu):\r
- """Popup menu for selecting playlist :class:`Tree` actions.\r
- """\r
- def __init__(self, on_delete, *args, **kwargs):\r
- super(Menu, self).__init__(*args, **kwargs)\r
- self._c = {\r
- 'delete': self.Append(id=wx.ID_ANY, text='Delete'),\r
- }\r
- self.Bind(wx.EVT_MENU, on_delete)\r
-\r
-\r
-class Tree (wx.TreeCtrl):\r
- """:class:`wx.TreeCtrl` subclass handling playlist and curve selection.\r
- """\r
- def __init__(self, config, callbacks, *args, **kwargs):\r
- super(Tree, self).__init__(*args, **kwargs)\r
- imglist = wx.ImageList(width=16, height=16, mask=True, initialCount=2)\r
- imglist.Add(wx.ArtProvider.GetBitmap(\r
- wx.ART_FOLDER, wx.ART_OTHER, wx.Size(16, 16)))\r
- imglist.Add(wx.ArtProvider.GetBitmap(\r
- wx.ART_NORMAL_FILE, wx.ART_OTHER, wx.Size(16, 16)))\r
- self.AssignImageList(imglist)\r
- self.image = {\r
- 'root': 0,\r
- 'playlist': 0,\r
- 'curve': 1,\r
- }\r
- self._c = {\r
- 'menu': Menu(self._on_delete),\r
- 'root': self.AddRoot('Playlists', self.image['root'])\r
- }\r
- self.Bind(wx.EVT_RIGHT_DOWN, self._on_context_menu)\r
- self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_curve_select)\r
- self.Bind(wx.EVT_LEFT_DOWN, self._on_left_down)\r
- self.Bind(wx.EVT_LEFT_DCLICK, self._on_left_doubleclick)\r
-\r
- self.config = config\r
- self._callbacks = callbacks\r
- self._setup_playlists()\r
-\r
- def _setup_playlists(self):\r
- self._playlists = {} # {name: hooke.playlist.Playlist()}\r
-\r
- # In both of the following dicts, curve names are\r
- # (playlist.name, curve.name) to avoid cross-playlist\r
- # collisions. See ._is_curve().\r
- self._id_for_name = {} # {name: id}\r
- self._name_for_id = {} # {id: name}\r
-\r
- def _name(self, name):\r
- """Cleanup names according to configured preferences.\r
- """\r
- if self.config['hide extensions'] == 'True': # HACK: config should decode\r
- name,ext = os.path.splitext(name)\r
- return name\r
-\r
- def _is_curve(self, name): # name from ._id_for_name / ._name_for_id\r
- """Return `True` if `name` corresponds to a :class:`hooke.curve.Curve`.\r
- """\r
- # Playlist names are strings, Curve names are tuples.\r
- # See ._setup_playlists().\r
- return not isinstance(name, types.StringTypes)\r
-\r
- def _on_curve_select(self, event):\r
- """Act on playlist/curve selection.\r
-\r
- Currently just a hook for a potential callback.\r
- """\r
- _id = self.GetSelection()\r
- name = self._name_for_id(_id)\r
- if self._is_curve(name):\r
- playlist = self._playlists[name[0]]\r
- curve = playlist.current()\r
- in_callback(self, playlist, curve)\r
-\r
- def _on_left_down(self, event):\r
- """Select the clicked-on curve/playlist.\r
- """ # TODO: dup with _on_curve_select?\r
- hit_id, hit_flags = self.HitTest(event.GetPosition())\r
- if (hit_flags & wx.TREE_HITTEST_ONITEM) != 0:\r
- name = self._name_for_id[hit_id]\r
- if self._is_curve(name):\r
- self.set_selected_curve(name[0], name[1])\r
- else:\r
- self.set_selected_playlist(name)\r
- event.Skip()\r
-\r
- def _on_left_doubleclick(self, event):\r
- playlist.index = index\r
- event.Skip()\r
-\r
- def _on_context_menu(self, event):\r
- """Launch a popup :class:`Menu` with per-playlist/curve activities.\r
- """\r
- hit_id,hit_flags = self.HitTest(event.GetPosition())\r
- if (hit_flags & wx.TREE_HITTEST_ONITEM) != 0:\r
- self._hit_id = hit_id # store for the callbacks\r
- self.PopupMenu(\r
- Menu(self._on_delete), event.GetPoint())\r
- menu.Destroy()\r
-\r
- def _on_delete(self, event):\r
- """Handler for :class:`Menu`'s `Delete` button.\r
-\r
- Determines the clicked item and calls the appropriate\r
- `.delete_*()` method on it.\r
- """\r
- if hasattr(self, '_hit_id'): # called via ._c['menu']\r
- _id = self._hit_id\r
- del(self._hit_id)\r
- name = self._name_for_id[_id]\r
- if self._is_curve(name):\r
- self.delete_curve(playlist_name=name[0], name=name[1])\r
- else:\r
- self.delete_playlist(name)\r
-\r
- def add_playlist(self, playlist):\r
- """Add a :class:`hooke.playlist.Playlist` to the tree.\r
- """\r
- if playlist.name not in self._playlists:\r
- pass\r
- else:\r
- raise ValueError('duplicate playlist: %s' % playlist.name)\r
- self._playlists[playlist.name] = playlist\r
- p_id = self.AppendItem(\r
- parent=self._c['root'],\r
- text=self._name(playlist.name),\r
- image=self.image['playlist'])\r
- self._id_for_name[playlist.name] = p_id\r
-\r
- # temporarily disable any add_curve callbacks\r
- acc = self._callbacks.get('add_curve', None)\r
- self._callbacks['add_curve'] = None\r
-\r
- for curve in playlist:\r
- self.add_curve(playlist.name, curve)\r
-\r
- # restore the add_curve callback\r
- self._callbacks['add_curve'] = acc\r
-\r
- in_callback(self, playlist)\r
-\r
- def add_curve(self, playlist_name, curve):\r
- """Add a :class:`hooke.curve.Curve` to a curently loaded playlist.\r
- """\r
- p = self._playlists[playlist_name]\r
- p.append(curve)\r
- c_id = AppendItem(\r
- parent=self._id_for_name[playlist_name],\r
- text=self._name(curve.name),\r
- image=self.image['curve'])\r
- self._id_for_name[(playlist.name, curve.name)] = c_id\r
- in_callback(self, p, curve)\r
-\r
- def delete_playlist(self, name):\r
- """Delete a :class:`hooke.playlist.Playlist` by name.\r
- """\r
- _id = self._id_for_name.pop(name)\r
- self.Delete(_id)\r
- playlist = self._playlists.pop(name)\r
- del(self._name_for_id[_id])\r
- for curve in playlist:\r
- _id = self._id_for_name.pop((name, curve.name))\r
- del(self._name_for_id[_id])\r
- in_callback(self, playlist)\r
-\r
- def delete_curve(self, playlist_name, name):\r
- """Delete a :class:`hooke.curve.Curve` by name.\r
- """\r
- if playlist is not None:\r
- if playlist.count == 1:\r
- notebook = self.Parent.plotNotebook\r
- index = self.Parent._GetPlaylistTab(playlist.name)\r
- notebook.SetSelection(index)\r
- notebook.DeletePage(notebook.GetSelection())\r
- self.Parent.DeleteFromPlaylists(playlist.name)\r
- else:\r
- file_name = self.GetItemText(item)\r
- playlist.delete_file(file_name)\r
- self.Delete(item)\r
- self.Parent.UpdatePlaylistsTreeSelection()\r
- in_callback(self, playlist, curve)\r
-\r
- def get_selected_playlist(self):\r
- """Return the selected :class:`hooke.playlist.Playlist`.\r
- """\r
- _id = self.GetSelection()\r
- name = self._name_for_id(_id)\r
- if self._is_curve(name):\r
- name = name[0]\r
- return self._playlists[name]\r
-\r
- def get_selected_curve(self):\r
- """Return the selected :class:`hooke.curve.Curve`.\r
- """\r
- _id = self.GetSelection()\r
- name = self._name_for_id(_id)\r
- if self._is_curve(name):\r
- p_name,c_name = name\r
- playlist = self._playlists[p_name]\r
- index = [i for i,c in enumerate(playlist) if c.name == c_name]\r
- playlist.jump(index)\r
- else:\r
- playlist = self._playlists[name]\r
- return playlist.current()\r
-\r
- def set_selected_playlist(self, name):\r
- """Set the selected :class:`hooke.playlist.Playlist` by name.\r
- """\r
- playlist = self._playlists[name]\r
- curve = playlist.current()\r
- self.set_selected_curve(playlist.name, curve.name)\r
-\r
- def set_selected_curve(self, playlist_name, name):\r
- """Set the selected :class:`hooke.curve.Curve` by name.\r
- """\r
- playlist = self._playlists[playlist.name]\r
- for i,curve in enumerate(playlist):\r
- if curve.name == name:\r
- playlist.jump(i)\r
- break\r
- curve = playlist.current()\r
- _id = self._id_for_name[(playlist.name, curve.name)]\r
- self.Expand(self._id_for_name[playlist.name])\r
- self.SelectItem(_id)\r
- in_callback(self, playlist, curve) # TODO: dup callback with _on_curve_select\r
-\r
- @callback\r
- def generate_new_playlist(self):\r
- pass\r
-\r
- def _GetUniquePlaylistName(self, name):\r
- playlist_name = name\r
- count = 1\r
- while playlist_name in self.playlists:\r
- playlist_name = ''.join([name, str(count)])\r
- count += 1\r
- return playlist_name\r
-\r
-\r
-class Playlist (wx.Panel):\r
- """:class:`wx.Panel` subclass wrapper for :class:`Tree`.\r
- """\r
- def __init__(self, config, callbacks, *args, **kwargs):\r
- # Use the WANTS_CHARS style so the panel doesn't eat the Return key.\r
- super(Playlist, self).__init__(*args, **kwargs)\r
- self.name = 'playlist panel'\r
-\r
- self._c = {\r
- 'tree': Tree(\r
- config=config,\r
- callbacks=callbacks,\r
- parent=self,\r
- size=wx.Size(160, 250),\r
- style=wx.TR_DEFAULT_STYLE | wx.NO_BORDER | wx.TR_HIDE_ROOT),\r
- }\r
-\r
- sizer = wx.BoxSizer(wx.VERTICAL)\r
- sizer.Add(self._c['tree'], 1, wx.EXPAND)\r
- self.SetSizer(sizer)\r
- sizer.Fit(self)\r
-\r
- # Expose all Tree's public curve/playlist methods directly.\r
- for attribute_name in dir(self._c['tree']):\r
- if (attribute_name.startswith('_')\r
- or 'playlist' not in attribute_name\r
- or 'curve' not in attribute_name):\r
- continue # not an attribute we're interested in\r
- attr = getattr(self._c['tree'], attribute_name)\r
- if hasattr(attr, '__call__'): # attr is a function / method\r
- setattr(self, attribute_name, attr) # expose it\r
+# Copyright (C) 2010-2012 W. Trevor King <wking@drexel.edu>
+#
+# 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 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 <http://www.gnu.org/licenses/>.
+
+"""Playlist panel for Hooke.
+
+Provides a nice GUI interface to the
+:class:`~hooke.plugin.playlist.PlaylistPlugin`.
+"""
+
+import logging
+import types
+
+import wx
+
+from ....util.callback import callback, in_callback
+from . import Panel
+
+
+class Menu (wx.Menu):
+ """Popup menu for selecting playlist :class:`Tree` actions.
+ """
+ def __init__(self, on_delete, *args, **kwargs):
+ super(Menu, self).__init__(*args, **kwargs)
+ self._c = {
+ 'delete': self.Append(id=wx.ID_ANY, text='Delete'),
+ }
+ self.Bind(wx.EVT_MENU, on_delete)
+
+
+class Tree (wx.TreeCtrl):
+ """:class:`wx.TreeCtrl` subclass handling playlist and curve selection.
+ """
+ def __init__(self, *args, **kwargs):
+ self.log = logging.getLogger('hooke')
+ self._panel = kwargs['parent']
+ self._callbacks = self._panel._callbacks # TODO: CallbackClass.set_callback{,s}()
+ super(Tree, self).__init__(*args, **kwargs)
+ imglist = wx.ImageList(width=16, height=16, mask=True, initialCount=2)
+ imglist.Add(wx.ArtProvider.GetBitmap(
+ wx.ART_FOLDER, wx.ART_OTHER, wx.Size(16, 16)))
+ imglist.Add(wx.ArtProvider.GetBitmap(
+ wx.ART_NORMAL_FILE, wx.ART_OTHER, wx.Size(16, 16)))
+ self.AssignImageList(imglist)
+ self.image = {
+ 'root': 0,
+ 'playlist': 0,
+ 'curve': 1,
+ }
+ self._c = {
+ 'menu': Menu(self._on_delete),
+ 'root': self.AddRoot(text='Playlists', image=self.image['root'])
+ }
+ self.Bind(wx.EVT_RIGHT_DOWN, self._on_context_menu)
+ self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_select)
+
+ self._setup_playlists()
+
+ def _setup_playlists(self):
+ self._playlists = {} # {name: hooke.playlist.Playlist()}
+
+ # In both of the following dicts, curve names are
+ # (playlist.name, curve.name) to avoid cross-playlist
+ # collisions. See ._is_curve().
+ self._id_for_name = {} # {name: id}
+ self._name_for_id = {} # {id: name}
+
+ def _is_curve(self, name): # name from ._id_for_name / ._name_for_id
+ """Return `True` if `name` corresponds to a :class:`hooke.curve.Curve`.
+ """
+ # Playlist names are strings, Curve names are tuples.
+ # See ._setup_playlists().
+ return not isinstance(name, types.StringTypes)
+
+ def _canonical_id(self, _id):
+ """Return a canonical form of `_id` suitable for accessing `._name_for_id`.
+
+ For some reason, `.GetSelection()`, etc. return items that
+ hash differently than the original `.AppendItem()`-returned
+ IDs. This means that `._name_for_id[self.GetSelection()]`
+ will raise `KeyError`, even if there is an id `X` in
+ `._name_for_id` for which `X == self.GetSelection()` will
+ return `True`. This method "canonicalizes" IDs so that the
+ hashing is consistent.
+ """
+ for c_id in self._name_for_id.keys():
+ if c_id == _id:
+ return c_id
+ raise KeyError(_id)
+
+
+ # Context menu
+
+ def _on_context_menu(self, event):
+ """Launch a popup :class:`Menu` with per-playlist/curve activities.
+ """
+ hit_id,hit_flags = self.HitTest(event.GetPosition())
+ if (hit_flags & wx.TREE_HITTEST_ONITEM) != 0:
+ self._hit_id = self._canonical_id(hit_id) # store for callbacks
+ menu = Menu(self._on_delete)
+ self.PopupMenu(menu, event.GetPosition())
+ menu.Destroy()
+
+ # Add
+ # add_* called directly by HookeFrame
+ # _add_* called on every addition
+
+ def add_playlist(self, playlist):
+ """Add a :class:`hooke.playlist.Playlist` to the tree.
+
+ Calls :meth:`_add_playlist` and triggers a callback.
+ """
+ self._add_playlist(playlist)
+ in_callback(self, playlist)
+
+ def _add_playlist(self, playlist):
+ """Add a class:`hooke.playlist.Playlist` to the tree.
+
+ No callback triggered.
+ """
+ if playlist.name not in self._playlists:
+ pass
+ else:
+ raise ValueError('duplicate playlist: %s' % playlist.name)
+ self._playlists[playlist.name] = playlist
+ p_id = self.AppendItem(
+ parent=self._c['root'],
+ text=self._panel._hooke_frame._file_name(playlist.name),
+ image=self.image['playlist'])
+ self._id_for_name[playlist.name] = p_id
+ self._name_for_id[p_id] = playlist.name
+ for curve in playlist:
+ self._add_curve(playlist.name, curve)
+
+ def add_curve(self, playlist_name, curve):
+ """Add a :class:`hooke.curve.Curve` to a curently loaded playlist.
+
+ Calls :meth:`_add_curve` and triggers a callback.
+ """
+ self._add_curve(playlist_name, curve)
+ playlist = self._playlists[playlist_name]
+ in_callback(self, playlist, curve)
+
+ def _add_curve(self, playlist_name, curve):
+ """Add a class:`hooke.curve.Curve` to the tree.
+
+ No callback triggered.
+ """
+ p = self._playlists[playlist_name]
+ if curve not in p:
+ p.append(curve)
+ c_id = self.AppendItem(
+ parent=self._id_for_name[playlist_name],
+ text=self._panel._hooke_frame._file_name(curve.name),
+ image=self.image['curve'])
+ self._id_for_name[(p.name, curve.name)] = c_id
+ self._name_for_id[c_id] = (p.name, curve.name)
+
+ @callback
+ def generate_new_playlist(self):
+ pass # TODO
+
+ def _GetUniquePlaylistName(self, name): # TODO
+ playlist_name = name
+ count = 1
+ while playlist_name in self.playlists:
+ playlist_name = ''.join([name, str(count)])
+ count += 1
+ return playlist_name
+
+ # Delete
+ # delete_* called by _on_delete handler (user click) or HookeFrame
+ # _delete_* called on every deletion
+
+ def _on_delete(self, event):
+ """Handler for :class:`Menu`'s `Delete` button.
+
+ Determines the clicked item and calls the appropriate
+ `.delete_*()` method on it.
+ """
+ #if hasattr(self, '_hit_id'): # called via ._c['menu']
+ _id = self._hit_id
+ del(self._hit_id)
+ name = self._name_for_id[_id]
+ if self._is_curve(name):
+ self._delete_curve(playlist_name=name[0], name=name[1])
+ else:
+ self._delete_playlist(name)
+
+ def _delete_playlist(self, name):
+ """Delete a :class:`hooke.playlist.Playlist` by name.
+
+ Called by the :meth:`_on_delete` handler. Calls the
+ approptiate interface callback.
+ """
+ _id = self._id_for_name[name]
+ playlist = self._playlists[name]
+ in_callback(self, playlist)
+
+ def delete_playlist(self, playlist):
+ """Respond to playlist deletion.
+
+ Called on *every* playlist deletion.
+ """
+ self._playlists.pop(playlist.name)
+ _id = self._id_for_name.pop(playlist.name)
+ self.Delete(_id)
+ del(self._name_for_id[_id])
+ for curve in playlist:
+ self._delete_curve(playlist, curve)
+
+ def _delete_curve(self, playlist_name, name):
+ """Delete a :class:`hooke.curve.Curve` by name.
+
+ Called by the :meth:`_on_delete` handler. Calls the
+ approptiate interface callback.
+ """
+ _id = self._id_for_name[(playlist_name, name)]
+ playlist = self._playlists[playlist_name]
+ curve = None
+ for i,c in enumerate(playlist):
+ if c.name == name:
+ curve = c
+ break
+ if curve is None:
+ raise ValueError(name)
+ in_callback(self, playlist, curve)
+
+ def delete_curve(self, playlist_name, name):
+ """Respond to curve deletions.
+
+ Called on *every* curve deletion.
+ """
+ _id = self._id_for_name.pop((playlist_name, name))
+ self.Delete(_id)
+ del(self._name_for_id[_id])
+
+ # Get selection
+
+ def get_selected_playlist(self):
+ """Return the selected :class:`hooke.playlist.Playlist`.
+ """
+ _id = self.GetSelection()
+ try:
+ _id = self._canonical_id(_id)
+ except KeyError: # no playlist selected
+ return None
+ name = self._name_for_id[_id]
+ if self._is_curve(name):
+ name = name[0]
+ return self._playlists[name]
+
+ def get_selected_curve(self):
+ """Return the selected :class:`hooke.curve.Curve`.
+ """
+ _id = self.GetSelection()
+ name = self._name_for_id[self._canonical_id(_id)]
+ if self._is_curve(name):
+ p_name,c_name = name
+ playlist = self._playlists[p_name]
+ c = playlist.current()
+ assert c.name == c_name, '%s != %s' % (c.name, c_name)
+ else:
+ playlist = self._playlists[name]
+ return playlist.current()
+
+ # Set selection (via user interaction with this panel)
+ #
+ # These are hooks for HookeFrame callbacks which will send
+ # the results back via 'get curve' calling 'set_selected_curve'.
+
+ def _on_select(self, event):
+ """Select the clicked-on curve/playlist.
+ """
+ _id = self.GetSelection()
+ name = self._name_for_id[self._canonical_id(_id)]
+ if self._is_curve(name):
+ p_name,c_name = name
+ self._on_set_selected_curve(p_name, c_name)
+ else:
+ self._on_set_selected_playlist(name)
+
+ def _on_set_selected_playlist(self, name):
+ self.log.debug('playlist tree selecting playlist %s' % name)
+ in_callback(self, self._playlists[name])
+
+ def _on_set_selected_curve(self, playlist_name, name):
+ self.log.debug('playlist tree selecting curve %s in %s'
+ % (name, playlist_name))
+ playlist = self._playlists[playlist_name]
+ curve = None
+ for i,c in enumerate(playlist):
+ if c.name == name:
+ curve = c
+ break
+ if curve == None:
+ raise ValueError(name)
+ in_callback(self, playlist, curve)
+
+ # Set selection (from the HookeFrame)
+
+ def set_selected_curve(self, playlist, curve):
+ """Make the curve the playlist's current curve.
+ """
+ self.log.debug('playlist tree expanding %s' % playlist.name)
+ self.Expand(self._id_for_name[playlist.name])
+ self.Unbind(wx.EVT_TREE_SEL_CHANGED)
+ self.log.debug('playlist tree selecting %s' % curve.name)
+ self.SelectItem(self._id_for_name[(playlist.name, curve.name)])
+ self.Bind(wx.EVT_TREE_SEL_CHANGED, self._on_select)
+
+ def update_playlist(self, playlist):
+ """Absorb changed `.index()`, etc.
+ """
+ self._playlists[playlist.name] = playlist
+ cnames = set()
+ for curve in playlist:
+ if (playlist.name, curve.name) not in self._id_for_name:
+ self._add_curve(playlist.name, curve)
+ cnames.add(curve.name)
+ for name in self._id_for_name.keys():
+ if not self._is_curve(name):
+ continue
+ pname,cname = name
+ if pname != playlist.name:
+ continue
+ if cname not in cnames:
+ self.delete_curve(playlist_name=pname, name=cname)
+
+ def is_playlist_loaded(self, playlist):
+ """Return `True` if `playlist` is loaded, `False` otherwise.
+ """
+ return self.is_playlist_name_loaded(playlist.name)
+
+ def is_playlist_name_loaded(self, name):
+ """Return `True` if a playlist named `name` is loaded, `False`
+ otherwise.
+ """
+ return name in self._playlists
+
+
+class Playlist (Panel, wx.Panel):
+ """:class:`wx.Panel` subclass wrapper for :class:`Tree`.
+ """
+ def __init__(self, callbacks=None, **kwargs):
+ # Use the WANTS_CHARS style so the panel doesn't eat the Return key.
+ super(Playlist, self).__init__(
+ name='playlist', callbacks=callbacks, **kwargs)
+ self._c = {
+ 'tree': Tree(
+ parent=self,
+ size=wx.Size(160, 250),
+ style=wx.TR_DEFAULT_STYLE | wx.NO_BORDER | wx.TR_HIDE_ROOT),
+ }
+
+ sizer = wx.BoxSizer(wx.VERTICAL)
+ sizer.Add(self._c['tree'], 1, wx.EXPAND)
+ self.SetSizer(sizer)
+ sizer.Fit(self)
+
+ # Expose all Tree's public curve/playlist methods directly.
+ # Following DRY and the LoD.
+ for attribute_name in dir(self._c['tree']):
+ if (attribute_name.startswith('_')
+ or ('playlist' not in attribute_name
+ and 'curve' not in attribute_name)):
+ continue # not an attribute we're interested in
+ attr = getattr(self._c['tree'], attribute_name)
+ if hasattr(attr, '__call__'): # attr is a function / method
+ setattr(self, attribute_name, attr) # expose it