Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--addons/EmbeddedInterpreter.py30
-rw-r--r--addons/WidgetIdentifier.py35
-rw-r--r--addons/bubblemessage.py19
-rw-r--r--addons/chainaction.py44
-rw-r--r--addons/clickaction.py52
-rw-r--r--addons/dialogmessage.py8
-rw-r--r--addons/disablewidget.py59
-rw-r--r--addons/eventgenerator.py60
-rw-r--r--addons/gtkwidgeteventfilter.py10
-rw-r--r--addons/gtkwidgettypefilter.py100
-rw-r--r--addons/oncewrapper.py59
-rw-r--r--addons/readfile.py6
-rw-r--r--addons/timerevent.py12
-rw-r--r--addons/triggereventfilter.py46
-rw-r--r--addons/typetextaction.py57
-rw-r--r--addons/widgetidentifyaction.py47
-rw-r--r--data/icons/Layer 1.svg6
-rw-r--r--data/icons/chain.svg65
-rw-r--r--data/icons/clock.sugar.svg1593
-rw-r--r--data/icons/clock.svg269
-rw-r--r--data/icons/once_wrapper.svg74
-rw-r--r--data/ui/creator.glade209
-rwxr-xr-xsetup.py6
-rw-r--r--tests/actiontests.py169
-rw-r--r--tests/addontests.py50
-rw-r--r--tests/bundlertests.py65
-rw-r--r--tests/constraintstests.py57
-rw-r--r--tests/coretests.py428
-rw-r--r--tests/enginetests.py30
-rw-r--r--tests/filterstests.py22
-rw-r--r--tests/inject.py57
-rw-r--r--tests/linear_creatortests.py10
-rw-r--r--tests/probetests.py478
-rw-r--r--tests/propertiestests.py249
-rw-r--r--tests/ressources/icon.svg21
-rwxr-xr-xtests/run-tests.py77
-rw-r--r--tests/serializertests.py197
-rw-r--r--tests/skip1
-rw-r--r--tests/storetests.py140
-rw-r--r--tests/translatortests.py131
-rw-r--r--tests/tutorialtests.py416
-rw-r--r--tests/utils.py49
-rw-r--r--tests/vaulttests.py489
-rw-r--r--tutorius/TProbe.py171
-rw-r--r--tutorius/actions.py159
-rw-r--r--tutorius/addon.py21
-rw-r--r--tutorius/apilib/__init__.py0
-rw-r--r--tutorius/apilib/__init__.pycbin0 -> 105 bytes
-rw-r--r--tutorius/apilib/httplib2/.svn/all-wcprops29
-rw-r--r--tutorius/apilib/httplib2/.svn/entries66
-rw-r--r--tutorius/apilib/httplib2/.svn/format1
-rw-r--r--tutorius/apilib/httplib2/.svn/prop-base/__init__.pyc.svn-base5
-rw-r--r--tutorius/apilib/httplib2/.svn/prop-base/iri2uri.pyc.svn-base5
-rw-r--r--tutorius/apilib/httplib2/.svn/text-base/__init__.py.svn-base1123
-rw-r--r--tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-basebin0 -> 39171 bytes
-rw-r--r--tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base110
-rw-r--r--tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-basebin0 -> 3632 bytes
-rw-r--r--tutorius/apilib/httplib2/__init__.py1123
-rw-r--r--tutorius/apilib/httplib2/__init__.pycbin0 -> 39750 bytes
-rw-r--r--tutorius/apilib/httplib2/iri2uri.py110
-rw-r--r--tutorius/apilib/httplib2/iri2uri.pycbin0 -> 3674 bytes
-rw-r--r--tutorius/apilib/mimeTypes.py57
-rw-r--r--tutorius/apilib/mimeTypes.pycbin0 -> 2249 bytes
-rw-r--r--tutorius/apilib/restful_lib.py129
-rw-r--r--tutorius/apilib/restful_lib.pycbin0 -> 4972 bytes
-rw-r--r--tutorius/bundler.py573
-rw-r--r--tutorius/constraints.py61
-rw-r--r--tutorius/core.py147
-rw-r--r--tutorius/creator.py649
-rw-r--r--tutorius/dbustools.py1
-rw-r--r--tutorius/editor.py2
-rw-r--r--tutorius/editor_interpreter.py105
-rw-r--r--tutorius/engine.py8
-rw-r--r--tutorius/events.py36
-rw-r--r--tutorius/filters.py2
-rw-r--r--tutorius/ipython_view.py301
-rw-r--r--tutorius/linear_creator.py8
-rw-r--r--tutorius/overlayer.py4
-rw-r--r--tutorius/properties.py101
-rw-r--r--tutorius/service.py67
-rw-r--r--tutorius/store.py473
-rw-r--r--tutorius/translator.py186
-rw-r--r--tutorius/tutorial.py829
-rw-r--r--tutorius/uam/__init__.py3
-rw-r--r--tutorius/vault.py982
-rw-r--r--tutorius/viewer.py423
86 files changed, 12438 insertions, 1634 deletions
diff --git a/addons/EmbeddedInterpreter.py b/addons/EmbeddedInterpreter.py
new file mode 100644
index 0000000..8c3522e
--- /dev/null
+++ b/addons/EmbeddedInterpreter.py
@@ -0,0 +1,30 @@
+from sugar.tutorius.actions import Action
+from sugar.tutorius.editor_interpreter import EditorInterpreter
+from sugar.tutorius.services import ObjectStore
+
+class EmbeddedInterpreter(Action):
+ def __init__(self):
+ Action.__init__(self)
+ self.activity = None
+ self._dialog = None
+
+ def do(self):
+ os = ObjectStore()
+ if os.activity:
+ self.activity = os.activity
+
+ self._dialog = EditorInterpreter(self.activity)
+ self._dialog.show()
+
+
+ def undo(self):
+ if self._dialog:
+ self._dialog.destroy()
+
+__action__ = {
+ "name" : "EmbeddedInterpreter",
+ "display_name" : "Embedded Interpreter",
+ "icon" : "message-bubble",
+ "class" : EmbeddedInterpreter,
+ "mandatory_props" : []
+}
diff --git a/addons/WidgetIdentifier.py b/addons/WidgetIdentifier.py
new file mode 100644
index 0000000..3c559b5
--- /dev/null
+++ b/addons/WidgetIdentifier.py
@@ -0,0 +1,35 @@
+from sugar.tutorius.actions import Action
+from sugar.tutorius.editor import WidgetIdentifier as WIPrimitive
+from sugar.tutorius.services import ObjectStore
+
+class WidgetIdentifier(Action):
+ def __init__(self):
+ Action.__init__(self)
+ self.activity = None
+ self._dialog = None
+
+ def do(self):
+ os = ObjectStore()
+ if os.activity:
+ self.activity = os.activity
+
+ self._dialog = WIPrimitive(self.activity)
+ self._dialog.show()
+
+
+ def undo(self):
+ if self._dialog:
+ # TODO elavoie 2009-07-19
+ # We should disconnect the handlers, however there seems to be an error
+ # saying that the size of the dictionary changed during the iteration
+ # We should investigate this
+ #self._dialog._disconnect_handlers()
+ self._dialog.destroy()
+
+__action__ = {
+ "name" : "WidgetIdentifier",
+ "display_name" : "Widget Identifier",
+ "icon" : "message-bubble",
+ "class" : WidgetIdentifier,
+ "mandatory_props" : []
+}
diff --git a/addons/bubblemessage.py b/addons/bubblemessage.py
index b8f7405..6572a6a 100644
--- a/addons/bubblemessage.py
+++ b/addons/bubblemessage.py
@@ -13,7 +13,10 @@
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
-from sugar.tutorius.actions import *
+from ..actions import Action, DragWrapper
+from ..properties import TStringProperty, TArrayProperty
+from .. import overlayer
+from ..services import ObjectStore
class BubbleMessage(Action):
message = TStringProperty("Message")
@@ -22,22 +25,22 @@ class BubbleMessage(Action):
# Do the same for the tail position
tail_pos = TArrayProperty((0,0), 2, 2)
- def __init__(self, message=None, pos=None, speaker=None, tailpos=None):
+ def __init__(self, message=None, position=None, speaker=None, tail_pos=None):
"""
Shows a dialog with a given text, at the given position on the screen.
@param message A string to display to the user
- @param pos A list of the form [x, y]
+ @param position A list of the form [x, y]
@param speaker treeish representation of the speaking widget
- @param tailpos The position of the tail of the bubble; useful to point to
+ @param tail_pos The position of the tail of the bubble; useful to point to
specific elements of the interface
"""
Action.__init__(self)
- if pos:
- self.position = pos
- if tailpos:
- self.tail_pos = tailpos
+ if position:
+ self.position = position
+ if tail_pos:
+ self.tail_pos = tail_pos
if message:
self.message = message
diff --git a/addons/chainaction.py b/addons/chainaction.py
new file mode 100644
index 0000000..8df7ac8
--- /dev/null
+++ b/addons/chainaction.py
@@ -0,0 +1,44 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from ..actions import *
+
+class ChainAction(Action):
+ actions = TAddonListProperty()
+
+ """Utility class to allow executing actions in a specific order"""
+ def __init__(self, actions=[]):
+ """ChainAction(action1, ... ) builds a chain of actions"""
+ Action.__init__(self)
+ self.actions = actions
+
+ def do(self,**kwargs):
+ """do() each action in the chain"""
+ for act in self.actions:
+ act.do(**kwargs)
+
+ def undo(self):
+ """undo() each action in the chain, starting with the last"""
+ for act in reversed(self.actions):
+ act.undo()
+
+__action__ = {
+ 'name': 'ChainAction',
+ 'display_name' : 'Chain of actions',
+ 'icon' : 'chain',
+ 'class' : ChainAction,
+ 'mandatory_props' : ['actions']
+}
diff --git a/addons/clickaction.py b/addons/clickaction.py
new file mode 100644
index 0000000..88c5519
--- /dev/null
+++ b/addons/clickaction.py
@@ -0,0 +1,52 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from .. import gtkutils
+from ..actions import *
+
+class ClickAction(Action):
+ """
+ Action that simulate a click on a widget
+ Work on any widget that implements a clicked() method
+
+ @param widget The threehish representation of the widget
+ """
+ widget = TStringProperty("")
+ def __init__(self, widget):
+ Action.__init__(self)
+ self.widget = widget
+
+ def do(self):
+ """
+ click the widget
+ """
+ realWidget = gtkutils.find_widget(ObjectStore().activity, self.widget)
+ if hasattr(realWidget, "clicked"):
+ realWidget.clicked()
+
+ def undo(self):
+ """
+ No undo
+ """
+ pass
+
+__action__ = {
+ 'name' : 'ClickAction',
+ 'display_name' : 'Click',
+ 'icon' : 'format-justify-center',
+ 'class' : ClickAction,
+ 'mandatory_props' : ['widget']
+}
diff --git a/addons/dialogmessage.py b/addons/dialogmessage.py
index bdd4bc3..9250693 100644
--- a/addons/dialogmessage.py
+++ b/addons/dialogmessage.py
@@ -16,25 +16,25 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
-from sugar.tutorius.actions import *
+from ..actions import *
class DialogMessage(Action):
message = TStringProperty("Message")
position = TArrayProperty((0, 0), 2, 2)
- def __init__(self, message=None, pos=None):
+ def __init__(self, message=None, position=None):
"""
Shows a dialog with a given text, at the given position on the screen.
@param message A string to display to the user
- @param pos A list of the form [x, y]
+ @param position A list of the form [x, y]
"""
super(DialogMessage, self).__init__()
self._dialog = None
if message:
self.message = message
- if pos: self.position = pos
+ if position: self.position = position
def do(self):
"""
diff --git a/addons/disablewidget.py b/addons/disablewidget.py
new file mode 100644
index 0000000..fd88303
--- /dev/null
+++ b/addons/disablewidget.py
@@ -0,0 +1,59 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from ..actions import *
+from .. import gtkutils
+from ..services import ObjectStore
+
+class DisableWidgetAction(Action):
+ target = TStringProperty("0")
+
+ def __init__(self, target):
+ """Constructor
+ @param target target treeish
+ """
+ Action.__init__(self)
+ if target is not None:
+ self.target = target
+ self._widget = None
+
+ def do(self):
+ """Action do"""
+ os = ObjectStore()
+ if os.activity:
+ self._widget = gtkutils.find_widget(os.activity, self.target)
+ if self._widget:
+ # If we have an object whose sensitivity we can query, we will
+ # keep it to reset it in the undo() method
+ if hasattr(self._widget, 'get_sensitive') and callable(self._widget.get_sensitive):
+ self._previous_sensitivity = self._widget.get_sensitive()
+ self._widget.set_sensitive(False)
+
+ def undo(self):
+ """Action undo"""
+ if self._widget:
+ if hasattr(self, '_previous_sensitivity'):
+ self._widget.set_sensitive(self._previous_sensitivity)
+ else:
+ self._widget.set_sensitive(True)
+
+__action__ = {
+ 'name' : 'DisableWidgetAction',
+ 'display_name' : 'Disable Widget',
+ 'icon' : 'stop',
+ 'class' : DisableWidgetAction,
+ 'mandatory_props' : ['target']
+}
diff --git a/addons/eventgenerator.py b/addons/eventgenerator.py
new file mode 100644
index 0000000..a91ccf4
--- /dev/null
+++ b/addons/eventgenerator.py
@@ -0,0 +1,60 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+from sugar.tutorius.actions import *
+from sugar.tutorius.gtkutils import find_widget
+
+class EventGenerator(Action):
+ source = TUAMProperty("")
+ type = TStringProperty("clicked")
+
+ def __init__(self, source=None, type="clicked"):
+ Action.__init__(self)
+
+ if source != None:
+ self.source = source
+
+ if type != "clicked":
+ self.type = type
+
+ def do(self):
+ self._activity = ObjectStore().activity
+
+ # TODO elavoie 2009-07-25 We should eventually use the UAM mecanism
+ widget = find_widget(self._activity, self.source)
+
+
+ # TODO elavoie 2009-07-25 We assume a gtk activity, it might
+ # get messier with other widget systems
+
+ # Call the signal on the widget
+ # We use introspection here to obtain the
+ # method that will send the corresponding
+ # signal on the gtk object
+ getattr(widget, self.type)()
+
+ # That's all!!!
+
+ def undo(self):
+ pass
+
+__action__ = {
+ "name" : "EventGenerator",
+ "display_name" : "Event Generator",
+ "icon" : "message-bubble",
+ "class" : EventGenerator,
+ "mandatory_props" : ["source", "type"]
+}
+
diff --git a/addons/gtkwidgeteventfilter.py b/addons/gtkwidgeteventfilter.py
index 5497af4..65aa744 100644
--- a/addons/gtkwidgeteventfilter.py
+++ b/addons/gtkwidgeteventfilter.py
@@ -13,16 +13,16 @@
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
-from sugar.tutorius.filters import EventFilter
-from sugar.tutorius.properties import TUAMProperty, TStringProperty
-from sugar.tutorius.gtkutils import find_widget
+from ..filters import EventFilter
+from ..properties import TUAMProperty, TEventType
+from ..gtkutils import find_widget
class GtkWidgetEventFilter(EventFilter):
"""
Basic Event filter for Gtk widget events
"""
object_id = TUAMProperty()
- event_name = TStringProperty("clicked")
+ event_name = TEventType('clicked')
def __init__(self, object_id=None, event_name=None):
"""Constructor
@@ -64,6 +64,6 @@ __event__ = {
"display_name" : "GTK Event catcher",
"icon" : "player_play",
"class" : GtkWidgetEventFilter,
- "mandatory_props" : ["object_id"]
+ "mandatory_props" : ["object_id", "event_name"]
}
diff --git a/addons/gtkwidgettypefilter.py b/addons/gtkwidgettypefilter.py
new file mode 100644
index 0000000..4ffecb5
--- /dev/null
+++ b/addons/gtkwidgettypefilter.py
@@ -0,0 +1,100 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from ..filters import *
+from ..properties import *
+from ..services import ObjectStore
+from ..gtkutils import find_widget
+
+import logging
+logger = logging.getLogger("GtkWidgetTypeFilter")
+
+class GtkWidgetTypeFilter(EventFilter):
+ """
+ Event Filter that listens for keystrokes on a widget
+ """
+ object_id = TStringProperty("")
+ text = TStringProperty("")
+ strokes = TArrayProperty([])
+
+ def __init__(self, object_id, text=None, strokes=None):
+ """Constructor
+ @param next_state default EventFilter param, passed on to EventFilter
+ @param object_id object tree-ish identifier
+ @param text resulting text expected
+ @param strokes list of strokes expected
+
+ At least one of text or strokes must be supplied
+ """
+ super(GtkWidgetTypeFilter, self).__init__()
+ self.object_id = object_id
+ self.text = text
+ self._captext = ""
+ self.strokes = strokes
+ self._capstrokes = []
+ self._widget = None
+ self._handler_id = None
+
+ def install_handlers(self, callback, **kwargs):
+ """install handlers
+ @param callback default EventFilter callback arg
+ """
+ super(GtkWidgetTypeFilter, self).install_handlers(callback, **kwargs)
+ logger.debug("~~~GtkWidgetTypeFilter install")
+ activity = ObjectStore().activity
+ if activity is None:
+ logger.error("No activity")
+ raise RuntimeWarning("no activity in the objectstore")
+
+ self._widget = find_widget(activity, self.object_id)
+ if self._widget:
+ self._handler_id= self._widget.connect("key-press-event",self.__keypress_cb)
+ logger.debug("~~~Connected handler %d on %s" % (self._handler_id,self.object_id) )
+
+ def remove_handlers(self):
+ """remove handlers"""
+ super(GtkWidgetTypeFilter, self).remove_handlers()
+ #if an event was connected, disconnect it
+ if self._handler_id:
+ self._widget.handler_disconnect(self._handler_id)
+ self._handler_id=None
+
+ def __keypress_cb(self, widget, event, *args):
+ """keypress callback"""
+ logger.debug("~~~keypressed!")
+ key = event.keyval
+ keystr = event.string
+ logger.debug("~~~Got key: " + str(key) + ":"+ keystr)
+ self._capstrokes += [key]
+ #TODO Treat other stuff, such as arrows
+ if key == gtk.keysyms.BackSpace:
+ self._captext = self._captext[:-1]
+ else:
+ self._captext = self._captext + keystr
+
+ logger.debug("~~~Current state: " + str(self._capstrokes) + ":" + str(self._captext))
+ if not self.strokes is None and self.strokes in self._capstrokes:
+ self.do_callback()
+ if not self.text is None and self.text in self._captext:
+ self.do_callback()
+
+__event__ = {
+ 'name' : 'GtkWidgetTypeFilter',
+ 'display_name' : 'Widget Filter',
+ 'icon' : '',
+ 'class' : GtkWidgetTypeFilter,
+ 'mandatory_props' : ['next_state', 'object_id']
+}
diff --git a/addons/oncewrapper.py b/addons/oncewrapper.py
new file mode 100644
index 0000000..5db3b60
--- /dev/null
+++ b/addons/oncewrapper.py
@@ -0,0 +1,59 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from ..actions import *
+
+class OnceWrapper(Action):
+ """
+ Wraps a class to perform an action once only
+
+ This ConcreteActions's do() method will only be called on the first do()
+ and the undo() will be callable after do() has been called
+ """
+
+ action = TAddonProperty()
+
+ def __init__(self, action):
+ Action.__init__(self)
+ self._called = False
+ self._need_undo = False
+ self.action = action
+
+ def do(self):
+ """
+ Do the action only on the first time
+ """
+ if not self._called:
+ self._called = True
+ self.action.do()
+ self._need_undo = True
+
+ def undo(self):
+ """
+ Undo the action if it's been done
+ """
+ if self._need_undo:
+ self.action.undo()
+ self._need_undo = False
+
+
+__action__ = {
+ 'name' : 'OnceWrapper',
+ 'display_name' : 'Execute an action only once',
+ 'icon' : 'once_wrapper',
+ 'class' : OnceWrapper,
+ 'mandatory_props' : ['action']
+}
diff --git a/addons/readfile.py b/addons/readfile.py
index 0d276b9..d3b5d76 100644
--- a/addons/readfile.py
+++ b/addons/readfile.py
@@ -16,9 +16,9 @@
import os
-from sugar.tutorius.actions import Action
-from sugar.tutorius.properties import TFileProperty
-from sugar.tutorius.services import ObjectStore
+from .actions import Action
+from .properties import TFileProperty
+from .services import ObjectStore
class ReadFile(Action):
filename = TFileProperty(None)
diff --git a/addons/timerevent.py b/addons/timerevent.py
index 7b4292c..cb312c4 100644
--- a/addons/timerevent.py
+++ b/addons/timerevent.py
@@ -16,8 +16,8 @@
import gobject
-from sugar.tutorius.filters import EventFilter
-from sugar.tutorius.properties import TIntProperty
+from .filters import EventFilter
+from .properties import TIntProperty
class TimerEvent(EventFilter):
"""
@@ -26,10 +26,11 @@ class TimerEvent(EventFilter):
of time. It must be used inside a gobject main loop to work.
"""
timeout = TIntProperty(15, 0)
+
def __init__(self, timeout=None):
"""Constructor.
- @param timeout_s timeout in seconds
+ @param timeout timeout in seconds
"""
super(TimerEvent,self).__init__()
if timeout:
@@ -65,9 +66,8 @@ class TimerEvent(EventFilter):
__event__ = {
"name" : "TimerEvent",
- "display_name" : "Timer",
- "icon" : "player_play",
+ "display_name" : "Timed transition",
+ "icon" : "clock",
"class" : TimerEvent,
"mandatory_props" : ["timeout"]
}
-
diff --git a/addons/triggereventfilter.py b/addons/triggereventfilter.py
new file mode 100644
index 0000000..19544b0
--- /dev/null
+++ b/addons/triggereventfilter.py
@@ -0,0 +1,46 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from ..filters import *
+from ..properties import *
+
+class TriggerEventFilter(EventFilter):
+ """
+ This event filter can be triggered by simply calling its do_callback function.
+
+ Used to fake events and see the effect on the FSM.
+ """
+ def __init__(self):
+ EventFilter.__init__(self)
+ self.toggle_on_callback = False
+
+ def install_handlers(self, callback, **kwargs):
+ """
+ Forsakes the incoming callback function and just set the inner one.
+ """
+ self._callback = self._inner_cb
+
+ def _inner_cb(self, event_filter):
+ self.toggle_on_callback = not self.toggle_on_callback
+
+__event__ = {
+ 'name' : 'TriggerEventFilter',
+ 'display_name' : 'Triggerable event filter (test only)',
+ 'icon' : '',
+ 'class' : TriggerEventFilter,
+ 'mandatory_props' : [],
+ 'test' : True
+}
diff --git a/addons/typetextaction.py b/addons/typetextaction.py
new file mode 100644
index 0000000..8b746e6
--- /dev/null
+++ b/addons/typetextaction.py
@@ -0,0 +1,57 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from ..actions import *
+from .. import gtkutils
+
+class TypeTextAction(Action):
+ """
+ Simulate a user typing text in a widget
+ Work on any widget that implements a insert_text method
+
+ @param widget The treehish representation of the widget
+ @param text the text that is typed
+ """
+ widget = TStringProperty("")
+ text = TStringProperty("")
+
+ def __init__(self, widget, text):
+ Action.__init__(self)
+
+ self.widget = widget
+ self.text = text
+
+ def do(self, **kwargs):
+ """
+ Type the text
+ """
+ widget = gtkutils.find_widget(ObjectStore().activity, self.widget)
+ if hasattr(widget, "insert_text"):
+ widget.insert_text(self.text, -1)
+
+ def undo(self):
+ """
+ no undo
+ """
+ pass
+
+__action__ = {
+ 'name' : 'TypeTextAction',
+ 'display_name' : 'Type text',
+ 'icon' : 'format-justify-center',
+ 'class' : TypeTextAction,
+ 'mandatory_props' : ['widgetUAM', 'text']
+}
diff --git a/addons/widgetidentifyaction.py b/addons/widgetidentifyaction.py
new file mode 100644
index 0000000..3df244b
--- /dev/null
+++ b/addons/widgetidentifyaction.py
@@ -0,0 +1,47 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from ..actions import *
+
+from ..editor import WidgetIdentifier
+
+class WidgetIdentifyAction(Action):
+ def __init__(self):
+ Action.__init__(self)
+ self.activity = None
+ self._dialog = None
+
+ def do(self):
+ os = ObjectStore()
+ if os.activity:
+ self.activity = os.activity
+
+ self._dialog = WidgetIdentifier(self.activity)
+ self._dialog.show()
+
+
+ def undo(self):
+ if self._dialog:
+ self._dialog.destroy()
+
+__action__ = {
+ "name" : 'WidgetIdentifyAction',
+ "display_name" : 'Widget Identifier',
+ "icon" : 'viewmag1',
+ "class" : WidgetIdentifyAction,
+ "mandatory_props" : [],
+ 'test' : True
+}
diff --git a/data/icons/Layer 1.svg b/data/icons/Layer 1.svg
new file mode 100644
index 0000000..e7d9e2b
--- /dev/null
+++ b/data/icons/Layer 1.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd' [
+ <!ENTITY stroke_color "#666666">
+ <!ENTITY fill_color "#ffffff">
+]><svg height="48px" id="svg2597" inkscape:output_extension="org.inkscape.output.svg.inkscape" inkscape:version="0.46" sodipodi:docname="chain.svg" sodipodi:version="0.32" width="48px" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"><g display="block" id="layer1" inkscape:groupmode="layer" inkscape:label="Layer 1">
+ <path d="M 9.8339096,17.521424 L 11.225374,16.510468 L 21.895596,24.262843 L 20.957347,27.150437 L 19.395745,28.285012 L 20.561807,24.696201 L 11.225374,17.912898 L 10.202552,18.656048 L 9.8339096,17.521424 z M 21.057049,9.3673619 L 31.727272,1.6149871 L 42.397446,9.3673619 L 39.590553,18.005887 L 38.62545,17.304719 L 41.063657,9.8007699 L 31.727272,3.0174177 L 22.390838,9.8007699 L 23.597982,13.515988 L 23.001478,15.351781 L 21.057049,9.3673619 z M 24.194484,19.023319 L 24.790989,17.187573 L 25.957048,20.776336 L 37.497447,20.776336 L 37.888118,19.573873 L 38.853268,20.275089 L 38.321764,21.910912 L 25.132685,21.910912 L 24.194484,19.023319 z M 0.5551853,24.262843 L 7.9036154,18.923855 L 8.2723049,20.058432 L 1.8889674,24.696201 L 5.4551984,35.671819 L 16.995595,35.671819 L 18.202737,31.9566 L 19.764434,30.821973 L 17.819959,36.806394 L 4.6308143,36.806394 L 0.5551853,24.262843 z M 31.476438,20.209046 L 36.567095,16.510468 L 47.237269,24.262843 L 43.161633,36.806394 L 34.078493,36.806394 L 34.447138,35.671819 L 42.337264,35.671819 L 45.903479,24.696201 L 36.567095,17.912898 L 33.406728,20.209046 L 31.476438,20.209046 z M 5.3950189,9.3673619 L 16.065194,1.6149871 L 23.413707,6.9539745 L 22.44856,7.6551909 L 16.065194,3.0174177 L 6.7288079,9.8007699 L 10.29502,20.776336 L 14.201416,20.776336 L 15.763019,21.910912 L 9.4707017,21.910912 L 5.3950189,9.3673619 z M 18.061955,20.776336 L 21.835416,20.776336 L 25.401627,9.8007699 L 24.378759,9.0576215 L 25.343953,8.3564062 L 26.735416,9.3673619 L 22.659781,21.910912 L 19.623558,21.910912 L 18.061955,20.776336 z M 14.494797,37.37368 L 15.687758,37.37368 L 18.126011,44.877733 L 29.666452,44.877733 L 33.232667,33.902157 L 30.072253,31.605965 L 29.475748,29.770175 L 34.566456,33.468751 L 30.490816,46.012316 L 17.301647,46.012316 L 14.494797,37.37368 z M 25.896871,24.262843 L 28.353226,22.478199 L 30.283475,22.478199 L 27.230659,24.696201 L 30.796869,35.671819 L 32.061166,35.671819 L 31.692527,36.806394 L 29.972505,36.806394 L 25.896871,24.262843 z M 13.226011,33.468751 L 23.896234,25.716376 L 26.352544,27.501018 L 26.949047,29.336809 L 23.896234,27.118808 L 14.559799,33.902157 L 14.950472,35.104529 L 13.757512,35.104529 L 13.226011,33.468751 z" id="path2535"/>
+ </g></svg> \ No newline at end of file
diff --git a/data/icons/chain.svg b/data/icons/chain.svg
new file mode 100644
index 0000000..e268f4f
--- /dev/null
+++ b/data/icons/chain.svg
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="48px"
+ height="48px"
+ id="svg2597"
+ sodipodi:version="0.32"
+ inkscape:version="0.46"
+ sodipodi:docname="chain.svg"
+ inkscape:output_extension="org.inkscape.output.svg.inkscape">
+ <defs
+ id="defs2599">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 24 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="48 : 24 : 1"
+ inkscape:persp3d-origin="24 : 16 : 1"
+ id="perspective2605" />
+ </defs>
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="7"
+ inkscape:cx="33.017737"
+ inkscape:cy="33.013898"
+ inkscape:current-layer="layer1"
+ showgrid="true"
+ inkscape:grid-bbox="true"
+ inkscape:document-units="px"
+ inkscape:window-width="645"
+ inkscape:window-height="726"
+ inkscape:window-x="625"
+ inkscape:window-y="25" />
+ <metadata
+ id="metadata2602">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ id="layer1"
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer">
+ <path
+ d="M 9.8339096,17.521424 L 11.225374,16.510468 L 21.895596,24.262843 L 20.957347,27.150437 L 19.395745,28.285012 L 20.561807,24.696201 L 11.225374,17.912898 L 10.202552,18.656048 L 9.8339096,17.521424 z M 21.057049,9.3673619 L 31.727272,1.6149871 L 42.397446,9.3673619 L 39.590553,18.005887 L 38.62545,17.304719 L 41.063657,9.8007699 L 31.727272,3.0174177 L 22.390838,9.8007699 L 23.597982,13.515988 L 23.001478,15.351781 L 21.057049,9.3673619 z M 24.194484,19.023319 L 24.790989,17.187573 L 25.957048,20.776336 L 37.497447,20.776336 L 37.888118,19.573873 L 38.853268,20.275089 L 38.321764,21.910912 L 25.132685,21.910912 L 24.194484,19.023319 z M 0.5551853,24.262843 L 7.9036154,18.923855 L 8.2723049,20.058432 L 1.8889674,24.696201 L 5.4551984,35.671819 L 16.995595,35.671819 L 18.202737,31.9566 L 19.764434,30.821973 L 17.819959,36.806394 L 4.6308143,36.806394 L 0.5551853,24.262843 z M 31.476438,20.209046 L 36.567095,16.510468 L 47.237269,24.262843 L 43.161633,36.806394 L 34.078493,36.806394 L 34.447138,35.671819 L 42.337264,35.671819 L 45.903479,24.696201 L 36.567095,17.912898 L 33.406728,20.209046 L 31.476438,20.209046 z M 5.3950189,9.3673619 L 16.065194,1.6149871 L 23.413707,6.9539745 L 22.44856,7.6551909 L 16.065194,3.0174177 L 6.7288079,9.8007699 L 10.29502,20.776336 L 14.201416,20.776336 L 15.763019,21.910912 L 9.4707017,21.910912 L 5.3950189,9.3673619 z M 18.061955,20.776336 L 21.835416,20.776336 L 25.401627,9.8007699 L 24.378759,9.0576215 L 25.343953,8.3564062 L 26.735416,9.3673619 L 22.659781,21.910912 L 19.623558,21.910912 L 18.061955,20.776336 z M 14.494797,37.37368 L 15.687758,37.37368 L 18.126011,44.877733 L 29.666452,44.877733 L 33.232667,33.902157 L 30.072253,31.605965 L 29.475748,29.770175 L 34.566456,33.468751 L 30.490816,46.012316 L 17.301647,46.012316 L 14.494797,37.37368 z M 25.896871,24.262843 L 28.353226,22.478199 L 30.283475,22.478199 L 27.230659,24.696201 L 30.796869,35.671819 L 32.061166,35.671819 L 31.692527,36.806394 L 29.972505,36.806394 L 25.896871,24.262843 z M 13.226011,33.468751 L 23.896234,25.716376 L 26.352544,27.501018 L 26.949047,29.336809 L 23.896234,27.118808 L 14.559799,33.902157 L 14.950472,35.104529 L 13.757512,35.104529 L 13.226011,33.468751 z"
+ id="path2535" />
+ </g>
+</svg>
diff --git a/data/icons/clock.sugar.svg b/data/icons/clock.sugar.svg
new file mode 100644
index 0000000..0334c1a
--- /dev/null
+++ b/data/icons/clock.sugar.svg
@@ -0,0 +1,1593 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ height="48"
+ id="svg2"
+ inkscape:output_extension="org.inkscape.output.svg.inkscape"
+ inkscape:version="0.46"
+ sodipodi:docbase="C:\Documents and Settings\Molumen\Desktop"
+ sodipodi:docname="clock.sugar.svg"
+ sodipodi:modified="true"
+ sodipodi:version="0.32"
+ version="1.0"
+ width="48">
+ <defs
+ id="defs4">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 115.5 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="231 : 115.5 : 1"
+ inkscape:persp3d-origin="115.5 : 77 : 1"
+ id="perspective228" />
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient20470"
+ inkscape:collect="always"
+ x1="302"
+ x2="302"
+ xlink:href="#linearGradient13034"
+ y1="365.95651"
+ y2="84.524567" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-340,200.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient20468"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-341,27.5432)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient20466"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="504.125"
+ cy="468.57623"
+ fx="504.125"
+ fy="468.57623"
+ gradientTransform="matrix(1.05261,0,0,1.05261,-26.5224,-23.8951)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient20464"
+ inkscape:collect="always"
+ r="2.625"
+ xlink:href="#linearGradient13172" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-744.784,-597.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient20462"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="302"
+ cy="239.93021"
+ fx="302"
+ fy="239.93021"
+ gradientTransform="matrix(3.14096,0,0,3.14096,-646.57,-549.905)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient20460"
+ inkscape:collect="always"
+ r="138"
+ xlink:href="#linearGradient20428" />
+ <radialGradient
+ cx="302"
+ cy="239.93021"
+ fx="302"
+ fy="239.93021"
+ gradientTransform="matrix(3.14096,0,0,3.14096,-646.57,-549.905)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient20438"
+ inkscape:collect="always"
+ r="138"
+ xlink:href="#linearGradient20428" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-744.784,-597.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient19456"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-341,27.5432)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient19449"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-340,200.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient19446"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient19441"
+ inkscape:collect="always"
+ x1="302"
+ x2="302"
+ xlink:href="#linearGradient13034"
+ y1="365.95651"
+ y2="84.524567" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-1,463.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient19439"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-2,290.543)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient19437"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="504.125"
+ cy="468.57623"
+ fx="504.125"
+ fy="468.57623"
+ gradientTransform="matrix(1.05261,0,0,1.05261,-26.5224,-23.8951)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient19435"
+ inkscape:collect="always"
+ r="2.625"
+ xlink:href="#linearGradient13172" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-405.784,-334.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient19433"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="302"
+ cy="239.93021"
+ fx="302"
+ fy="239.93021"
+ gradientTransform="matrix(3.14096,0,0,3.14096,-646.57,-549.905)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient19431"
+ inkscape:collect="always"
+ r="138"
+ xlink:href="#linearGradient13012" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16295"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-781.919,-183.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16293"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16285"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-781.919,-183.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16283"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16275"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-781.919,-183.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16273"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16265"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-781.919,-183.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16263"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16249"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-781.919,-183.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16241"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient16243" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16197"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16195"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16189"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16187"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16181"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16179"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16173"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16171"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16165"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16163"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16095"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,-173.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16093"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16091"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16089"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16087"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,-173.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16085"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16083"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16081"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16079"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,-173.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16077"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16075"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16073"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16071"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,-173.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16069"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16067"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16065"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16063"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,-173.605)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16061"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient15913" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16059"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.25538,-1.25538,2.63349,2.63349,-1015.92,24.3952)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient16057"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ <radialGradient
+ cx="202.5"
+ cy="578.86218"
+ fx="202.5"
+ fy="578.86218"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13728"
+ inkscape:collect="always"
+ r="91.5"
+ xlink:href="#linearGradient10759" />
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient13265"
+ inkscape:collect="always"
+ x1="302"
+ x2="302"
+ xlink:href="#linearGradient13034"
+ y1="365.95651"
+ y2="84.524567" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-1,463.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13263"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-2,290.543)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13261"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="504.125"
+ cy="468.57623"
+ fx="504.125"
+ fy="468.57623"
+ gradientTransform="matrix(1.05261,0,0,1.05261,-26.5224,-23.8951)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13259"
+ inkscape:collect="always"
+ r="2.625"
+ xlink:href="#linearGradient13172" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-405.784,-334.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13257"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="302"
+ cy="239.93021"
+ fx="302"
+ fy="239.93021"
+ gradientTransform="matrix(3.14096,0,0,3.14096,-646.57,-549.905)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13255"
+ inkscape:collect="always"
+ r="138"
+ xlink:href="#linearGradient13012" />
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient13231"
+ inkscape:collect="always"
+ x1="302"
+ x2="302"
+ xlink:href="#linearGradient13034"
+ y1="365.95651"
+ y2="84.524567" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-1,463.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13229"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-2,290.543)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13227"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="504.125"
+ cy="468.57623"
+ fx="504.125"
+ fy="468.57623"
+ gradientTransform="matrix(1.05261,0,0,1.05261,-26.5224,-23.8951)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13225"
+ inkscape:collect="always"
+ r="2.625"
+ xlink:href="#linearGradient13172" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-405.784,-334.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13223"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="302"
+ cy="239.93021"
+ fx="302"
+ fy="239.93021"
+ gradientTransform="matrix(3.14096,0,0,3.14096,-646.57,-549.905)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13221"
+ inkscape:collect="always"
+ r="138"
+ xlink:href="#linearGradient13012" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-405.784,-334.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13206"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-2,290.543)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13203"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-1,463.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13200"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient13195"
+ inkscape:collect="always"
+ x1="302"
+ x2="302"
+ xlink:href="#linearGradient13034"
+ y1="365.95651"
+ y2="84.524567" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-1,463.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13193"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-2,290.543)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13191"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-405.784,-334.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13189"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="302"
+ cy="239.93021"
+ fx="302"
+ fy="239.93021"
+ gradientTransform="matrix(3.14096,0,0,3.14096,-646.57,-549.905)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13187"
+ inkscape:collect="always"
+ r="138"
+ xlink:href="#linearGradient13012" />
+ <radialGradient
+ cx="504.125"
+ cy="468.57623"
+ fx="504.125"
+ fy="468.57623"
+ gradientTransform="matrix(1.05261,0,0,1.05261,-26.5224,-23.8951)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13170"
+ inkscape:collect="always"
+ r="2.625"
+ xlink:href="#linearGradient13172" />
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient13146"
+ inkscape:collect="always"
+ x1="302"
+ x2="302"
+ xlink:href="#linearGradient13034"
+ y1="365.95651"
+ y2="84.524567" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-405.784,-334.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13143"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-2,290.543)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13140"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-1,463.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13137"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient13133"
+ inkscape:collect="always"
+ x1="302"
+ x2="302"
+ xlink:href="#linearGradient13034"
+ y1="426.36218"
+ y2="150.36218" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-1,463.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13131"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-2,290.543)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13129"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-405.784,-334.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13127"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <radialGradient
+ cx="302"
+ cy="239.93021"
+ fx="302"
+ fy="239.93021"
+ gradientTransform="matrix(3.14096,0,0,3.14096,-646.57,-549.905)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13125"
+ inkscape:collect="always"
+ r="138"
+ xlink:href="#linearGradient13012" />
+ <linearGradient
+ gradientUnits="userSpaceOnUse"
+ id="linearGradient13032"
+ inkscape:collect="always"
+ x1="302"
+ x2="302"
+ xlink:href="#linearGradient13034"
+ y1="426.36218"
+ y2="150.36218" />
+ <radialGradient
+ cx="302"
+ cy="239.93021"
+ fx="302"
+ fy="239.93021"
+ gradientTransform="matrix(3.14096,0,0,3.14096,-646.57,-549.905)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13010"
+ inkscape:collect="always"
+ r="138"
+ xlink:href="#linearGradient13012" />
+ <radialGradient
+ cx="527"
+ cy="691.20294"
+ fx="527"
+ fy="691.20294"
+ gradientTransform="matrix(1,0,0,0.231842,-1,463.219)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient13000"
+ inkscape:collect="always"
+ r="90.78125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(-0.932879,0,0,-0.244839,1018.94,683.505)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient12987"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="528"
+ cy="368.17188"
+ fx="528"
+ fy="368.17188"
+ gradientTransform="matrix(1,0,0,0.262455,-2,290.543)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient12983"
+ inkscape:collect="always"
+ r="113.53125"
+ xlink:href="#linearGradient12977" />
+ <radialGradient
+ cx="525.49945"
+ cy="467.18744"
+ fx="525.49945"
+ fy="467.18744"
+ gradientTransform="matrix(1.77314,0,0,1.77314,-405.784,-334.014)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient12959"
+ inkscape:collect="always"
+ r="138"
+ spreadMethod="pad"
+ xlink:href="#linearGradient12953" />
+ <linearGradient
+ id="linearGradient5553">
+ <stop
+ id="stop5555"
+ offset="0"
+ style="stop-color: rgb(53, 155, 68); stop-opacity: 1;" />
+ <stop
+ id="stop5557"
+ offset="1"
+ style="stop-color: rgb(50, 204, 73); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient5613">
+ <stop
+ id="stop5615"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0.888889;" />
+ <stop
+ id="stop5617"
+ offset="0.5"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop5619"
+ offset="0.51142859"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop5621"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient5675">
+ <stop
+ id="stop5677"
+ offset="0"
+ style="stop-color: rgb(0, 138, 0); stop-opacity: 0.890196;" />
+ <stop
+ id="stop5679"
+ offset="0.5"
+ style="stop-color: rgb(72, 143, 51); stop-opacity: 0;" />
+ <stop
+ id="stop5681"
+ offset="0.51142859"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop5683"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient5563">
+ <stop
+ id="stop5565"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0.703704;" />
+ <stop
+ id="stop5571"
+ offset="0.50850612"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0.189815;" />
+ <stop
+ id="stop5573"
+ offset="0.51142859"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop5567"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient5537">
+ <stop
+ id="stop5539"
+ offset="0"
+ style="stop-color: rgb(74, 206, 96); stop-opacity: 1;" />
+ <stop
+ id="stop5541"
+ offset="1"
+ style="stop-color: rgb(180, 234, 135); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10743">
+ <stop
+ id="stop10745"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0.703704;" />
+ <stop
+ id="stop10747"
+ offset="0.50850612"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0.189815;" />
+ <stop
+ id="stop10749"
+ offset="0.51142859"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop10751"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10753">
+ <stop
+ id="stop10755"
+ offset="0"
+ style="stop-color: rgb(53, 155, 68); stop-opacity: 1;" />
+ <stop
+ id="stop10757"
+ offset="1"
+ style="stop-color: rgb(50, 204, 73); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10759">
+ <stop
+ id="stop10761"
+ offset="0"
+ style="stop-color: rgb(74, 206, 96); stop-opacity: 1;" />
+ <stop
+ id="stop10763"
+ offset="1"
+ style="stop-color: rgb(180, 234, 135); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10767">
+ <stop
+ id="stop10769"
+ offset="0"
+ style="stop-color: rgb(53, 155, 68); stop-opacity: 1;" />
+ <stop
+ id="stop10771"
+ offset="1"
+ style="stop-color: rgb(50, 204, 73); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10773">
+ <stop
+ id="stop10775"
+ offset="0"
+ style="stop-color: rgb(0, 138, 0); stop-opacity: 0.890196;" />
+ <stop
+ id="stop10777"
+ offset="0.5"
+ style="stop-color: rgb(72, 143, 51); stop-opacity: 0;" />
+ <stop
+ id="stop10779"
+ offset="0.51142859"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop10781"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10783">
+ <stop
+ id="stop10785"
+ offset="0"
+ style="stop-color: rgb(0, 138, 0); stop-opacity: 0.890196;" />
+ <stop
+ id="stop10787"
+ offset="0.5"
+ style="stop-color: rgb(72, 143, 51); stop-opacity: 0;" />
+ <stop
+ id="stop10789"
+ offset="0.51142859"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop10791"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10793">
+ <stop
+ id="stop10795"
+ offset="0"
+ style="stop-color: rgb(0, 138, 0); stop-opacity: 0.890196;" />
+ <stop
+ id="stop10797"
+ offset="0.5"
+ style="stop-color: rgb(72, 143, 51); stop-opacity: 0;" />
+ <stop
+ id="stop10799"
+ offset="0.51142859"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop10801"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10803">
+ <stop
+ id="stop10805"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0.888889;" />
+ <stop
+ id="stop10807"
+ offset="0.5"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop10809"
+ offset="0.51142859"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop10811"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10813">
+ <stop
+ id="stop10815"
+ offset="0"
+ style="stop-color: rgb(53, 155, 68); stop-opacity: 1;" />
+ <stop
+ id="stop10817"
+ offset="1"
+ style="stop-color: rgb(50, 204, 73); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient10819">
+ <stop
+ id="stop10821"
+ offset="0"
+ style="stop-color: rgb(74, 206, 96); stop-opacity: 1;" />
+ <stop
+ id="stop10823"
+ offset="1"
+ style="stop-color: rgb(180, 234, 135); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient12953">
+ <stop
+ id="stop12955"
+ offset="0"
+ style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" />
+ <stop
+ id="stop12965"
+ offset="0.47816542"
+ style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" />
+ <stop
+ id="stop12961"
+ offset="0.49808899"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop12967"
+ offset="0.50756544"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop12963"
+ offset="0.53007674"
+ style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" />
+ <stop
+ id="stop12957"
+ offset="1"
+ style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient12977">
+ <stop
+ id="stop12979"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0.319444;" />
+ <stop
+ id="stop12981"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient13012">
+ <stop
+ id="stop13014"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop13018"
+ offset="0.20165709"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop13020"
+ offset="0.32675916"
+ style="stop-color: rgb(239, 245, 251); stop-opacity: 1;" />
+ <stop
+ id="stop13016"
+ offset="1"
+ style="stop-color: rgb(4, 72, 127); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient13034">
+ <stop
+ id="stop13036"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" />
+ <stop
+ id="stop13038"
+ offset="1"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient13172">
+ <stop
+ id="stop13174"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop13176"
+ offset="1"
+ style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3346">
+ <stop
+ id="stop3348"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop3350"
+ offset="1"
+ style="stop-color: rgb(233, 233, 233); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3916">
+ <stop
+ id="stop3918"
+ offset="0"
+ style="stop-color: rgb(139, 139, 139); stop-opacity: 0.639175;" />
+ <stop
+ id="stop3924"
+ offset="0.44642857"
+ style="stop-color: rgb(141, 141, 141); stop-opacity: 0.206186;" />
+ <stop
+ id="stop3922"
+ offset="1"
+ style="stop-color: rgb(143, 143, 143); stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3440">
+ <stop
+ id="stop3442"
+ offset="0"
+ style="stop-color: black; stop-opacity: 1;" />
+ <stop
+ id="stop3452"
+ offset="0.3125"
+ style="stop-color: black; stop-opacity: 1;" />
+ <stop
+ id="stop3446"
+ offset="0.53727454"
+ style="stop-color: white; stop-opacity: 1;" />
+ <stop
+ id="stop3542"
+ offset="0.60522962"
+ style="stop-color: white; stop-opacity: 1;" />
+ <stop
+ id="stop3448"
+ offset="0.6964286"
+ style="stop-color: black; stop-opacity: 1;" />
+ <stop
+ id="stop3444"
+ offset="1"
+ style="stop-color: black; stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3757">
+ <stop
+ id="stop3759"
+ offset="0"
+ style="stop-color: black; stop-opacity: 1;" />
+ <stop
+ id="stop3761"
+ offset="1"
+ style="stop-color: black; stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient3584">
+ <stop
+ id="stop3586"
+ offset="0"
+ style="stop-color: white; stop-opacity: 1;" />
+ <stop
+ id="stop3592"
+ offset="1"
+ style="stop-color: white; stop-opacity: 0.498039;" />
+ <stop
+ id="stop3588"
+ offset="1"
+ style="stop-color: white; stop-opacity: 0;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient15913">
+ <stop
+ id="stop15915"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop15917"
+ offset="1"
+ style="stop-color: rgb(240, 216, 35); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient16243">
+ <stop
+ id="stop16245"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop16247"
+ offset="1"
+ style="stop-color: rgb(35, 178, 240); stop-opacity: 1;" />
+ </linearGradient>
+ <linearGradient
+ id="linearGradient20428">
+ <stop
+ id="stop20430"
+ offset="0"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop20432"
+ offset="0.20165709"
+ style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" />
+ <stop
+ id="stop20434"
+ offset="0.32675916"
+ style="stop-color: rgb(251, 248, 239); stop-opacity: 1;" />
+ <stop
+ id="stop20436"
+ offset="1"
+ style="stop-color: rgb(127, 98, 4); stop-opacity: 1;" />
+ </linearGradient>
+ <radialGradient
+ cx="296.26508"
+ cy="361.61154"
+ fx="296.26508"
+ fy="361.61154"
+ gradientTransform="matrix(1.03888,-1.98608,4.32211,2.26874,-1497.58,2.13654)"
+ gradientUnits="userSpaceOnUse"
+ id="radialGradient2855"
+ inkscape:collect="always"
+ r="131"
+ xlink:href="#linearGradient3346" />
+ </defs>
+ <sodipodi:namedview
+ bordercolor="#666666"
+ borderopacity="1.0"
+ gridtolerance="10000"
+ guidetolerance="10"
+ id="base"
+ inkscape:current-layer="layer1"
+ inkscape:cx="21.55618"
+ inkscape:cy="22.885983"
+ inkscape:document-units="px"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:window-height="726"
+ inkscape:window-width="1208"
+ inkscape:window-x="62"
+ inkscape:window-y="25"
+ inkscape:zoom="11.240376"
+ objecttolerance="10"
+ pagecolor="#ffffff"
+ style=""
+ showgrid="false" />
+ <metadata
+ id="metadata7">
+ <rdf:RDF
+ style="">
+ <cc:Work
+ rdf:about=""
+ style="">
+ <dc:format
+ style="">image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage"
+ style="" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ id="layer1"
+ inkscape:groupmode="layer"
+ inkscape:label="Layer 1"
+ transform="translate(-436.343,-114.661)">
+ <g
+ id="g19409"
+ transform="matrix(0.1657254,0,0,0.1657254,373.12967,56.687971)">
+ <path
+ d="M 440,288.36218 A 138,138 0 1 1 164,288.36218 A 138,138 0 1 1 440,288.36218 z"
+ id="path19411"
+ sodipodi:cx="302"
+ sodipodi:cy="288.36218"
+ sodipodi:rx="138"
+ sodipodi:ry="138"
+ sodipodi:type="arc"
+ style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+ transform="matrix(0.862319,0,0,0.862319,265.58,245.715)" />
+ <path
+ d="M 526,356.375 C 449.824,356.375 388,418.199 388,494.375 C 388,570.551 449.824,632.375 526,632.375 C 602.176,632.375 664,570.551 664,494.375 C 664,418.199 602.176,356.375 526,356.375 z M 526,375.375 C 591.688,375.375 645,428.687 645,494.375 C 644.99999,560.063 591.688,613.375 526,613.375 C 460.312,613.37499 407,560.063 407,494.375 C 407,428.687 460.312,375.375 526,375.375 z"
+ id="path19413"
+ style="fill:url(#radialGradient19433);fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+ <g
+ id="g19415">
+ <path
+ d="M 572.45442,416.66652 L 574.56216,417.96362 L 518.94889,510.35634 L 515.03453,507.94743 L 572.45442,416.66652 z"
+ id="path19417"
+ sodipodi:nodetypes="ccccc"
+ style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dashoffset:0;stroke-opacity:1" />
+ <path
+ d="M 470.10131,522.52645 L 468.3205,518.67564 L 539.27256,484.69517 L 541.94377,490.47138 L 470.10131,522.52645 z"
+ id="path19419"
+ sodipodi:nodetypes="ccccc"
+ style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dashoffset:0;stroke-opacity:1" />
+ <path
+ d="M 506.75,471.48718 A 2.625,2.625 0 1 1 501.5,471.48718 A 2.625,2.625 0 1 1 506.75,471.48718 z"
+ id="path19421"
+ sodipodi:cx="504.125"
+ sodipodi:cy="471.48718"
+ sodipodi:rx="2.625"
+ sodipodi:ry="2.625"
+ sodipodi:type="arc"
+ style="opacity:1;fill:url(#radialGradient19435);fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+ transform="matrix(1.85714,0,0,1.85714,-410.232,-381.244)" />
+ </g>
+ <path
+ d="M 526,357.375 C 478.94483,357.375 437.38188,380.97729 412.46875,416.96875 C 441.06326,387.02851 481.36166,368.375 526,368.375 C 570.63834,368.375 610.93674,387.02852 639.53125,416.96875 C 614.61812,380.97729 573.05517,357.375 526,357.375 z"
+ id="path19423"
+ style="fill:url(#radialGradient19437);fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+ <path
+ d="M 526,623.46875 C 562.37673,623.46875 594.94742,607.12155 616.78125,581.375 C 592.50974,602.60542 560.7553,615.46875 526,615.46875 C 491.2447,615.46875 459.49026,602.60542 435.21875,581.375 C 457.05258,607.12155 489.62327,623.46875 526,623.46875 z"
+ id="path19425"
+ style="fill:url(#radialGradient19439);fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+ <path
+ d="M 523.53125,378.40625 L 523.53125,402.125 C 525.1769,402.07398 526.82304,402.09578 528.46875,402.125 L 528.46875,378.40625 L 523.53125,378.40625 z M 468.9375,393.40625 L 467.09375,394.46875 L 477.0625,411.75 C 477.67174,411.38819 478.28844,411.03612 478.90625,410.6875 L 468.9375,393.40625 z M 583.0625,393.40625 L 573.09375,410.6875 C 573.71156,411.03612 574.32826,411.38819 574.9375,411.75 L 584.90625,394.46875 L 583.0625,393.40625 z M 426.09375,435.46875 L 425.03125,437.3125 L 442.3125,447.28125 C 442.66112,446.66344 443.01319,446.04674 443.375,445.4375 L 426.09375,435.46875 z M 625.90625,435.46875 L 608.625,445.4375 C 608.98681,446.04674 609.33888,446.66344 609.6875,447.28125 L 626.96875,437.3125 L 625.90625,435.46875 z M 410.03125,491.90625 L 410.03125,496.84375 L 433.75,496.84375 C 433.69898,495.1981 433.72078,493.55196 433.75,491.90625 L 410.03125,491.90625 z M 618.25,491.90625 C 618.30102,493.5519 618.27922,495.19804 618.25,496.84375 L 641.96875,496.84375 L 641.96875,491.90625 L 618.25,491.90625 z M 442.3125,541.46875 L 425.03125,551.4375 L 426.09375,553.28125 L 443.375,543.3125 C 443.01319,542.70326 442.66112,542.08656 442.3125,541.46875 z M 609.6875,541.46875 C 609.33888,542.08656 608.98681,542.70326 608.625,543.3125 L 625.90625,553.28125 L 626.96875,551.4375 L 609.6875,541.46875 z M 477.0625,577 L 467.09375,594.28125 L 468.9375,595.34375 L 478.90625,578.0625 C 478.28844,577.71388 477.67174,577.36181 477.0625,577 z M 574.9375,577 C 574.32826,577.36181 573.71156,577.71388 573.09375,578.0625 L 583.0625,595.34375 L 584.90625,594.28125 L 574.9375,577 z M 523.53125,586.625 L 523.53125,610.34375 L 528.46875,610.34375 L 528.46875,586.625 C 526.8231,586.67602 525.17696,586.65422 523.53125,586.625 z"
+ id="path19427"
+ sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+ <path
+ d="M 440,288.36218 A 138,138 0 1 1 164,288.36218 A 138,138 0 1 1 440,288.36218 z"
+ id="path19429"
+ sodipodi:cx="302"
+ sodipodi:cy="288.36218"
+ sodipodi:rx="138"
+ sodipodi:ry="138"
+ sodipodi:type="arc"
+ style="fill:url(#linearGradient19441);fill-opacity:1;fill-rule:nonzero;stroke:#666666;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
+ transform="matrix(0.728261,0,0,0.601449,306.065,286.927)" />
+ </g>
+ </g>
+</svg>
diff --git a/data/icons/clock.svg b/data/icons/clock.svg
new file mode 100644
index 0000000..dc73bbb
--- /dev/null
+++ b/data/icons/clock.svg
@@ -0,0 +1,269 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://web.resource.org/cc/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="51" height="51" id="svg2" sodipodi:version="0.32" inkscape:version="0.44+devel" sodipodi:docbase="C:\Documents and Settings\Molumen\Desktop" sodipodi:docname="clock_beige.svg" inkscape:output_extension="org.inkscape.output.svg.inkscape" sodipodi:modified="true" version="1.0">
+ <defs id="defs4">
+ <linearGradient y2="84.524567" x2="302" y1="365.95651" x1="302" gradientUnits="userSpaceOnUse" id="linearGradient20470" xlink:href="#linearGradient13034" inkscape:collect="always"/>
+ <radialGradient r="90.78125" fy="691.20294" fx="527" cy="691.20294" cx="527" gradientTransform="matrix(1, 0, 0, 0.231842, -340, 200.219)" gradientUnits="userSpaceOnUse" id="radialGradient20468" xlink:href="#linearGradient12977" inkscape:collect="always"/>
+ <radialGradient r="113.53125" fy="368.17188" fx="528" cy="368.17188" cx="528" gradientTransform="matrix(1, 0, 0, 0.262455, -341, 27.5432)" gradientUnits="userSpaceOnUse" id="radialGradient20466" xlink:href="#linearGradient12977" inkscape:collect="always"/>
+ <radialGradient r="2.625" fy="468.57623" fx="504.125" cy="468.57623" cx="504.125" gradientTransform="matrix(1.05261, 0, 0, 1.05261, -26.5224, -23.8951)" gradientUnits="userSpaceOnUse" id="radialGradient20464" xlink:href="#linearGradient13172" inkscape:collect="always"/>
+ <radialGradient r="138" fy="467.18744" fx="525.49945" cy="467.18744" cx="525.49945" spreadMethod="pad" gradientTransform="matrix(1.77314, 0, 0, 1.77314, -744.784, -597.014)" gradientUnits="userSpaceOnUse" id="radialGradient20462" xlink:href="#linearGradient12953" inkscape:collect="always"/>
+ <radialGradient r="138" fy="239.93021" fx="302" cy="239.93021" cx="302" gradientTransform="matrix(3.14096, 0, 0, 3.14096, -646.57, -549.905)" gradientUnits="userSpaceOnUse" id="radialGradient20460" xlink:href="#linearGradient20428" inkscape:collect="always"/>
+ <radialGradient r="138" fy="239.93021" fx="302" cy="239.93021" cx="302" gradientTransform="matrix(3.14096, 0, 0, 3.14096, -646.57, -549.905)" gradientUnits="userSpaceOnUse" id="radialGradient20438" xlink:href="#linearGradient20428" inkscape:collect="always"/>
+ <radialGradient r="138" fy="467.18744" fx="525.49945" cy="467.18744" cx="525.49945" spreadMethod="pad" gradientTransform="matrix(1.77314, 0, 0, 1.77314, -744.784, -597.014)" gradientUnits="userSpaceOnUse" id="radialGradient19456" xlink:href="#linearGradient12953" inkscape:collect="always"/>
+ <radialGradient r="113.53125" fy="368.17188" fx="528" cy="368.17188" cx="528" gradientTransform="matrix(1, 0, 0, 0.262455, -341, 27.5432)" gradientUnits="userSpaceOnUse" id="radialGradient19449" xlink:href="#linearGradient12977" inkscape:collect="always"/>
+ <radialGradient r="90.78125" fy="691.20294" fx="527" cy="691.20294" cx="527" gradientTransform="matrix(1, 0, 0, 0.231842, -340, 200.219)" gradientUnits="userSpaceOnUse" id="radialGradient19446" xlink:href="#linearGradient12977" inkscape:collect="always"/>
+ <linearGradient y2="84.524567" x2="302" y1="365.95651" x1="302" gradientUnits="userSpaceOnUse" id="linearGradient19441" xlink:href="#linearGradient13034" inkscape:collect="always"/>
+ <radialGradient r="90.78125" fy="691.20294" fx="527" cy="691.20294" cx="527" gradientTransform="matrix(1, 0, 0, 0.231842, -1, 463.219)" gradientUnits="userSpaceOnUse" id="radialGradient19439" xlink:href="#linearGradient12977" inkscape:collect="always"/>
+ <radialGradient r="113.53125" fy="368.17188" fx="528" cy="368.17188" cx="528" gradientTransform="matrix(1, 0, 0, 0.262455, -2, 290.543)" gradientUnits="userSpaceOnUse" id="radialGradient19437" xlink:href="#linearGradient12977" inkscape:collect="always"/>
+ <radialGradient r="2.625" fy="468.57623" fx="504.125" cy="468.57623" cx="504.125" gradientTransform="matrix(1.05261, 0, 0, 1.05261, -26.5224, -23.8951)" gradientUnits="userSpaceOnUse" id="radialGradient19435" xlink:href="#linearGradient13172" inkscape:collect="always"/>
+ <radialGradient r="138" fy="467.18744" fx="525.49945" cy="467.18744" cx="525.49945" spreadMethod="pad" gradientTransform="matrix(1.77314, 0, 0, 1.77314, -405.784, -334.014)" gradientUnits="userSpaceOnUse" id="radialGradient19433" xlink:href="#linearGradient12953" inkscape:collect="always"/>
+ <radialGradient r="138" fy="239.93021" fx="302" cy="239.93021" cx="302" gradientTransform="matrix(3.14096, 0, 0, 3.14096, -646.57, -549.905)" gradientUnits="userSpaceOnUse" id="radialGradient19431" xlink:href="#linearGradient13012" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16295" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -781.919, -183.605)" gradientUnits="userSpaceOnUse" id="radialGradient16293" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16285" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -781.919, -183.605)" gradientUnits="userSpaceOnUse" id="radialGradient16283" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16275" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -781.919, -183.605)" gradientUnits="userSpaceOnUse" id="radialGradient16273" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16265" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -781.919, -183.605)" gradientUnits="userSpaceOnUse" id="radialGradient16263" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16249" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -781.919, -183.605)" gradientUnits="userSpaceOnUse" id="radialGradient16241" xlink:href="#linearGradient16243" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16197" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16195" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16189" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16187" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16181" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16179" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16173" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16171" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16165" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16163" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16095" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, -173.605)" gradientUnits="userSpaceOnUse" id="radialGradient16093" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16091" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16089" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16087" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, -173.605)" gradientUnits="userSpaceOnUse" id="radialGradient16085" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16083" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16081" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16079" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, -173.605)" gradientUnits="userSpaceOnUse" id="radialGradient16077" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16075" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16073" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16071" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, -173.605)" gradientUnits="userSpaceOnUse" id="radialGradient16069" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16067" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16065" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16063" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, -173.605)" gradientUnits="userSpaceOnUse" id="radialGradient16061" xlink:href="#linearGradient15913" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" gradientUnits="userSpaceOnUse" id="radialGradient16059" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient r="131" fy="361.61154" fx="296.26508" cy="361.61154" cx="296.26508" gradientTransform="matrix(1.25538, -1.25538, 2.63349, 2.63349, -1015.92, 24.3952)" gradientUnits="userSpaceOnUse" id="radialGradient16057" xlink:href="#linearGradient3346" inkscape:collect="always"/>
+ <radialGradient xlink:href="#linearGradient10759" cx="202.5" cy="578.86218" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="578.86218" fx="202.5" id="radialGradient13728" r="91.5"/>
+ <linearGradient y2="84.524567" y1="365.95651" xlink:href="#linearGradient13034" x2="302" x1="302" inkscape:collect="always" gradientUnits="userSpaceOnUse" id="linearGradient13265"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.231842, -1, 463.219)" xlink:href="#linearGradient12977" cx="527" cy="691.20294" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="691.20294" fx="527" id="radialGradient13263" r="90.78125"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.262455, -2, 290.543)" xlink:href="#linearGradient12977" cx="528" cy="368.17188" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="368.17188" fx="528" id="radialGradient13261" r="113.53125"/>
+ <radialGradient gradientTransform="matrix(1.05261, 0, 0, 1.05261, -26.5224, -23.8951)" xlink:href="#linearGradient13172" cx="504.125" cy="468.57623" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="468.57623" fx="504.125" id="radialGradient13259" r="2.625"/>
+ <radialGradient gradientTransform="matrix(1.77314, 0, 0, 1.77314, -405.784, -334.014)" xlink:href="#linearGradient12953" cx="525.49945" cy="467.18744" inkscape:collect="always" gradientUnits="userSpaceOnUse" spreadMethod="pad" fy="467.18744" fx="525.49945" id="radialGradient13257" r="138"/>
+ <radialGradient gradientTransform="matrix(3.14096, 0, 0, 3.14096, -646.57, -549.905)" xlink:href="#linearGradient13012" cx="302" cy="239.93021" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="239.93021" fx="302" id="radialGradient13255" r="138"/>
+ <linearGradient y2="84.524567" y1="365.95651" xlink:href="#linearGradient13034" x2="302" x1="302" inkscape:collect="always" gradientUnits="userSpaceOnUse" id="linearGradient13231"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.231842, -1, 463.219)" xlink:href="#linearGradient12977" cx="527" cy="691.20294" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="691.20294" fx="527" id="radialGradient13229" r="90.78125"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.262455, -2, 290.543)" xlink:href="#linearGradient12977" cx="528" cy="368.17188" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="368.17188" fx="528" id="radialGradient13227" r="113.53125"/>
+ <radialGradient gradientTransform="matrix(1.05261, 0, 0, 1.05261, -26.5224, -23.8951)" xlink:href="#linearGradient13172" cx="504.125" cy="468.57623" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="468.57623" fx="504.125" id="radialGradient13225" r="2.625"/>
+ <radialGradient gradientTransform="matrix(1.77314, 0, 0, 1.77314, -405.784, -334.014)" xlink:href="#linearGradient12953" cx="525.49945" cy="467.18744" inkscape:collect="always" gradientUnits="userSpaceOnUse" spreadMethod="pad" fy="467.18744" fx="525.49945" id="radialGradient13223" r="138"/>
+ <radialGradient gradientTransform="matrix(3.14096, 0, 0, 3.14096, -646.57, -549.905)" xlink:href="#linearGradient13012" cx="302" cy="239.93021" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="239.93021" fx="302" id="radialGradient13221" r="138"/>
+ <radialGradient gradientTransform="matrix(1.77314, 0, 0, 1.77314, -405.784, -334.014)" xlink:href="#linearGradient12953" cx="525.49945" cy="467.18744" inkscape:collect="always" gradientUnits="userSpaceOnUse" spreadMethod="pad" fy="467.18744" fx="525.49945" id="radialGradient13206" r="138"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.262455, -2, 290.543)" xlink:href="#linearGradient12977" cx="528" cy="368.17188" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="368.17188" fx="528" id="radialGradient13203" r="113.53125"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.231842, -1, 463.219)" xlink:href="#linearGradient12977" cx="527" cy="691.20294" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="691.20294" fx="527" id="radialGradient13200" r="90.78125"/>
+ <linearGradient y2="84.524567" y1="365.95651" xlink:href="#linearGradient13034" x2="302" x1="302" inkscape:collect="always" gradientUnits="userSpaceOnUse" id="linearGradient13195"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.231842, -1, 463.219)" xlink:href="#linearGradient12977" cx="527" cy="691.20294" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="691.20294" fx="527" id="radialGradient13193" r="90.78125"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.262455, -2, 290.543)" xlink:href="#linearGradient12977" cx="528" cy="368.17188" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="368.17188" fx="528" id="radialGradient13191" r="113.53125"/>
+ <radialGradient gradientTransform="matrix(1.77314, 0, 0, 1.77314, -405.784, -334.014)" xlink:href="#linearGradient12953" cx="525.49945" cy="467.18744" inkscape:collect="always" gradientUnits="userSpaceOnUse" spreadMethod="pad" fy="467.18744" fx="525.49945" id="radialGradient13189" r="138"/>
+ <radialGradient gradientTransform="matrix(3.14096, 0, 0, 3.14096, -646.57, -549.905)" xlink:href="#linearGradient13012" cx="302" cy="239.93021" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="239.93021" fx="302" id="radialGradient13187" r="138"/>
+ <radialGradient gradientTransform="matrix(1.05261, 0, 0, 1.05261, -26.5224, -23.8951)" xlink:href="#linearGradient13172" cx="504.125" cy="468.57623" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="468.57623" fx="504.125" id="radialGradient13170" r="2.625"/>
+ <linearGradient y2="84.524567" y1="365.95651" xlink:href="#linearGradient13034" x2="302" x1="302" inkscape:collect="always" gradientUnits="userSpaceOnUse" id="linearGradient13146"/>
+ <radialGradient gradientTransform="matrix(1.77314, 0, 0, 1.77314, -405.784, -334.014)" xlink:href="#linearGradient12953" cx="525.49945" cy="467.18744" inkscape:collect="always" gradientUnits="userSpaceOnUse" spreadMethod="pad" fy="467.18744" fx="525.49945" id="radialGradient13143" r="138"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.262455, -2, 290.543)" xlink:href="#linearGradient12977" cx="528" cy="368.17188" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="368.17188" fx="528" id="radialGradient13140" r="113.53125"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.231842, -1, 463.219)" xlink:href="#linearGradient12977" cx="527" cy="691.20294" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="691.20294" fx="527" id="radialGradient13137" r="90.78125"/>
+ <linearGradient y2="150.36218" y1="426.36218" xlink:href="#linearGradient13034" x2="302" x1="302" inkscape:collect="always" gradientUnits="userSpaceOnUse" id="linearGradient13133"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.231842, -1, 463.219)" xlink:href="#linearGradient12977" cx="527" cy="691.20294" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="691.20294" fx="527" id="radialGradient13131" r="90.78125"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.262455, -2, 290.543)" xlink:href="#linearGradient12977" cx="528" cy="368.17188" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="368.17188" fx="528" id="radialGradient13129" r="113.53125"/>
+ <radialGradient gradientTransform="matrix(1.77314, 0, 0, 1.77314, -405.784, -334.014)" xlink:href="#linearGradient12953" cx="525.49945" cy="467.18744" inkscape:collect="always" gradientUnits="userSpaceOnUse" spreadMethod="pad" fy="467.18744" fx="525.49945" id="radialGradient13127" r="138"/>
+ <radialGradient gradientTransform="matrix(3.14096, 0, 0, 3.14096, -646.57, -549.905)" xlink:href="#linearGradient13012" cx="302" cy="239.93021" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="239.93021" fx="302" id="radialGradient13125" r="138"/>
+ <linearGradient y2="150.36218" y1="426.36218" xlink:href="#linearGradient13034" x2="302" x1="302" inkscape:collect="always" gradientUnits="userSpaceOnUse" id="linearGradient13032"/>
+ <radialGradient gradientTransform="matrix(3.14096, 0, 0, 3.14096, -646.57, -549.905)" xlink:href="#linearGradient13012" cx="302" cy="239.93021" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="239.93021" fx="302" id="radialGradient13010" r="138"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.231842, -1, 463.219)" xlink:href="#linearGradient12977" cx="527" cy="691.20294" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="691.20294" fx="527" id="radialGradient13000" r="90.78125"/>
+ <radialGradient gradientTransform="matrix(-0.932879, 0, 0, -0.244839, 1018.94, 683.505)" xlink:href="#linearGradient12977" cx="528" cy="368.17188" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="368.17188" fx="528" id="radialGradient12987" r="113.53125"/>
+ <radialGradient gradientTransform="matrix(1, 0, 0, 0.262455, -2, 290.543)" xlink:href="#linearGradient12977" cx="528" cy="368.17188" inkscape:collect="always" gradientUnits="userSpaceOnUse" fy="368.17188" fx="528" id="radialGradient12983" r="113.53125"/>
+ <radialGradient gradientTransform="matrix(1.77314, 0, 0, 1.77314, -405.784, -334.014)" xlink:href="#linearGradient12953" cx="525.49945" cy="467.18744" inkscape:collect="always" gradientUnits="userSpaceOnUse" spreadMethod="pad" fy="467.18744" fx="525.49945" id="radialGradient12959" r="138"/>
+ <linearGradient id="linearGradient5553">
+ <stop id="stop5555" style="stop-color: rgb(53, 155, 68); stop-opacity: 1;" offset="0"/>
+ <stop id="stop5557" style="stop-color: rgb(50, 204, 73); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient5613">
+ <stop id="stop5615" style="stop-color: rgb(255, 255, 255); stop-opacity: 0.888889;" offset="0"/>
+ <stop id="stop5617" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.5"/>
+ <stop id="stop5619" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.51142859"/>
+ <stop id="stop5621" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient5675">
+ <stop id="stop5677" style="stop-color: rgb(0, 138, 0); stop-opacity: 0.890196;" offset="0"/>
+ <stop id="stop5679" style="stop-color: rgb(72, 143, 51); stop-opacity: 0;" offset="0.5"/>
+ <stop id="stop5681" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.51142859"/>
+ <stop id="stop5683" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient5563">
+ <stop id="stop5565" style="stop-color: rgb(255, 255, 255); stop-opacity: 0.703704;" offset="0"/>
+ <stop id="stop5571" style="stop-color: rgb(255, 255, 255); stop-opacity: 0.189815;" offset="0.50850612"/>
+ <stop id="stop5573" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.51142859"/>
+ <stop id="stop5567" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient5537">
+ <stop id="stop5539" style="stop-color: rgb(74, 206, 96); stop-opacity: 1;" offset="0"/>
+ <stop id="stop5541" style="stop-color: rgb(180, 234, 135); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10743">
+ <stop id="stop10745" style="stop-color: rgb(255, 255, 255); stop-opacity: 0.703704;" offset="0"/>
+ <stop id="stop10747" style="stop-color: rgb(255, 255, 255); stop-opacity: 0.189815;" offset="0.50850612"/>
+ <stop id="stop10749" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.51142859"/>
+ <stop id="stop10751" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10753">
+ <stop id="stop10755" style="stop-color: rgb(53, 155, 68); stop-opacity: 1;" offset="0"/>
+ <stop id="stop10757" style="stop-color: rgb(50, 204, 73); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10759">
+ <stop id="stop10761" style="stop-color: rgb(74, 206, 96); stop-opacity: 1;" offset="0"/>
+ <stop id="stop10763" style="stop-color: rgb(180, 234, 135); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10767">
+ <stop id="stop10769" style="stop-color: rgb(53, 155, 68); stop-opacity: 1;" offset="0"/>
+ <stop id="stop10771" style="stop-color: rgb(50, 204, 73); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10773">
+ <stop id="stop10775" style="stop-color: rgb(0, 138, 0); stop-opacity: 0.890196;" offset="0"/>
+ <stop id="stop10777" style="stop-color: rgb(72, 143, 51); stop-opacity: 0;" offset="0.5"/>
+ <stop id="stop10779" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.51142859"/>
+ <stop id="stop10781" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10783">
+ <stop id="stop10785" style="stop-color: rgb(0, 138, 0); stop-opacity: 0.890196;" offset="0"/>
+ <stop id="stop10787" style="stop-color: rgb(72, 143, 51); stop-opacity: 0;" offset="0.5"/>
+ <stop id="stop10789" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.51142859"/>
+ <stop id="stop10791" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10793">
+ <stop id="stop10795" style="stop-color: rgb(0, 138, 0); stop-opacity: 0.890196;" offset="0"/>
+ <stop id="stop10797" style="stop-color: rgb(72, 143, 51); stop-opacity: 0;" offset="0.5"/>
+ <stop id="stop10799" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.51142859"/>
+ <stop id="stop10801" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10803">
+ <stop id="stop10805" style="stop-color: rgb(255, 255, 255); stop-opacity: 0.888889;" offset="0"/>
+ <stop id="stop10807" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.5"/>
+ <stop id="stop10809" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0.51142859"/>
+ <stop id="stop10811" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10813">
+ <stop id="stop10815" style="stop-color: rgb(53, 155, 68); stop-opacity: 1;" offset="0"/>
+ <stop id="stop10817" style="stop-color: rgb(50, 204, 73); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient10819">
+ <stop id="stop10821" style="stop-color: rgb(74, 206, 96); stop-opacity: 1;" offset="0"/>
+ <stop id="stop10823" style="stop-color: rgb(180, 234, 135); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient12953">
+ <stop id="stop12955" style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" offset="0"/>
+ <stop id="stop12965" style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" offset="0.47816542"/>
+ <stop id="stop12961" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="0.49808899"/>
+ <stop id="stop12967" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="0.50756544"/>
+ <stop id="stop12963" style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" offset="0.53007674"/>
+ <stop id="stop12957" style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient12977">
+ <stop id="stop12979" style="stop-color: rgb(255, 255, 255); stop-opacity: 0.319444;" offset="0"/>
+ <stop id="stop12981" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient13012">
+ <stop id="stop13014" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="0"/>
+ <stop id="stop13018" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="0.20165709"/>
+ <stop id="stop13020" style="stop-color: rgb(239, 245, 251); stop-opacity: 1;" offset="0.32675916"/>
+ <stop id="stop13016" style="stop-color: rgb(4, 72, 127); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient13034">
+ <stop id="stop13036" style="stop-color: rgb(255, 255, 255); stop-opacity: 0;" offset="0"/>
+ <stop id="stop13038" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient13172">
+ <stop id="stop13174" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="0"/>
+ <stop id="stop13176" style="stop-color: rgb(0, 0, 0); stop-opacity: 1;" offset="1"/>
+ </linearGradient>
+ <linearGradient id="linearGradient3346">
+ <stop style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="0" id="stop3348"/>
+ <stop style="stop-color: rgb(233, 233, 233); stop-opacity: 1;" offset="1" id="stop3350"/>
+ </linearGradient>
+ <filter id="filter4767" inkscape:collect="always">
+ <feGaussianBlur id="feGaussianBlur4769" stdDeviation="0.77125" inkscape:collect="always"/>
+ </filter>
+ <linearGradient id="linearGradient3916">
+ <stop style="stop-color: rgb(139, 139, 139); stop-opacity: 0.639175;" offset="0" id="stop3918"/>
+ <stop id="stop3924" offset="0.44642857" style="stop-color: rgb(141, 141, 141); stop-opacity: 0.206186;"/>
+ <stop id="stop3922" offset="1" style="stop-color: rgb(143, 143, 143); stop-opacity: 0;"/>
+ </linearGradient>
+ <linearGradient id="linearGradient3440">
+ <stop style="stop-color: black; stop-opacity: 1;" offset="0" id="stop3442"/>
+ <stop id="stop3452" offset="0.3125" style="stop-color: black; stop-opacity: 1;"/>
+ <stop id="stop3446" offset="0.53727454" style="stop-color: white; stop-opacity: 1;"/>
+ <stop style="stop-color: white; stop-opacity: 1;" offset="0.60522962" id="stop3542"/>
+ <stop style="stop-color: black; stop-opacity: 1;" offset="0.6964286" id="stop3448"/>
+ <stop style="stop-color: black; stop-opacity: 1;" offset="1" id="stop3444"/>
+ </linearGradient>
+ <linearGradient id="linearGradient3757">
+ <stop id="stop3759" offset="0" style="stop-color: black; stop-opacity: 1;"/>
+ <stop id="stop3761" offset="1" style="stop-color: black; stop-opacity: 0;"/>
+ </linearGradient>
+ <linearGradient id="linearGradient3584">
+ <stop id="stop3586" offset="0" style="stop-color: white; stop-opacity: 1;"/>
+ <stop style="stop-color: white; stop-opacity: 0.498039;" offset="1" id="stop3592"/>
+ <stop id="stop3588" offset="1" style="stop-color: white; stop-opacity: 0;"/>
+ </linearGradient>
+ <linearGradient id="linearGradient15913">
+ <stop id="stop15915" offset="0" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;"/>
+ <stop id="stop15917" offset="1" style="stop-color: rgb(240, 216, 35); stop-opacity: 1;"/>
+ </linearGradient>
+ <linearGradient id="linearGradient16243">
+ <stop style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" offset="0" id="stop16245"/>
+ <stop style="stop-color: rgb(35, 178, 240); stop-opacity: 1;" offset="1" id="stop16247"/>
+ </linearGradient>
+ <linearGradient id="linearGradient20428">
+ <stop offset="0" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" id="stop20430"/>
+ <stop offset="0.20165709" style="stop-color: rgb(255, 255, 255); stop-opacity: 1;" id="stop20432"/>
+ <stop offset="0.32675916" style="stop-color: rgb(251, 248, 239); stop-opacity: 1;" id="stop20434"/>
+ <stop offset="1" style="stop-color: rgb(127, 98, 4); stop-opacity: 1;" id="stop20436"/>
+ </linearGradient>
+ <radialGradient inkscape:collect="always" xlink:href="#linearGradient3346" id="radialGradient2855" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.03888, -1.98608, 4.32211, 2.26874, -1497.58, 2.13654)" cx="296.26508" cy="361.61154" fx="296.26508" fy="361.61154" r="131"/>
+ </defs>
+ <sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" gridtolerance="10000" guidetolerance="10" objecttolerance="10" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="0.49497475" inkscape:cx="448.89881" inkscape:cy="364.07566" inkscape:document-units="px" inkscape:current-layer="layer1" inkscape:window-width="1208" inkscape:window-height="1070" inkscape:window-x="222" inkscape:window-y="20"/>
+ <metadata id="metadata7">
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-436.343, -114.661)">
+ <g transform="matrix(0.836957, 0, 0, 0.836957, 111.604, -183.61)" id="g19409">
+ <path sodipodi:ry="138" sodipodi:rx="138" transform="matrix(0.862319, 0, 0, 0.862319, 265.58, 245.715)" d="M 440,288.36218 A 138,138 0 1 1 164,288.36218 A 138,138 0 1 1 440,288.36218 z" sodipodi:type="arc" sodipodi:cy="288.36218" sodipodi:cx="302" id="path19411" style="fill: url(#radialGradient19431) rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ <path d="M 526,356.375 C 449.824,356.375 388,418.199 388,494.375 C 388,570.551 449.824,632.375 526,632.375 C 602.176,632.375 664,570.551 664,494.375 C 664,418.199 602.176,356.375 526,356.375 z M 526,375.375 C 591.688,375.375 645,428.687 645,494.375 C 644.99999,560.063 591.688,613.375 526,613.375 C 460.312,613.37499 407,560.063 407,494.375 C 407,428.687 460.312,375.375 526,375.375 z" id="path19413" style="fill: url(#radialGradient19433) rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ <g id="g19415">
+ <path id="path19417" sodipodi:nodetypes="ccccc" d="M 572.45442,416.66652 L 574.56216,417.96362 L 518.94889,510.35634 L 515.03453,507.94743 L 572.45442,416.66652 z" style="fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ <path id="path19419" sodipodi:nodetypes="ccccc" d="M 470.10131,522.52645 L 468.3205,518.67564 L 539.27256,484.69517 L 541.94377,490.47138 L 470.10131,522.52645 z" style="fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ <path sodipodi:ry="2.625" sodipodi:rx="2.625" transform="matrix(1.85714, 0, 0, 1.85714, -410.232, -381.244)" d="M 506.75,471.48718 A 2.625,2.625 0 1 1 501.5,471.48718 A 2.625,2.625 0 1 1 506.75,471.48718 z" sodipodi:type="arc" sodipodi:cy="471.48718" sodipodi:cx="504.125" id="path19421" style="opacity: 1; fill: url(#radialGradient19435) rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ </g>
+ <path d="M 526,357.375 C 478.94483,357.375 437.38188,380.97729 412.46875,416.96875 C 441.06326,387.02851 481.36166,368.375 526,368.375 C 570.63834,368.375 610.93674,387.02852 639.53125,416.96875 C 614.61812,380.97729 573.05517,357.375 526,357.375 z" id="path19423" style="fill: url(#radialGradient19437) rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ <path d="M 526,623.46875 C 562.37673,623.46875 594.94742,607.12155 616.78125,581.375 C 592.50974,602.60542 560.7553,615.46875 526,615.46875 C 491.2447,615.46875 459.49026,602.60542 435.21875,581.375 C 457.05258,607.12155 489.62327,623.46875 526,623.46875 z" id="path19425" style="fill: url(#radialGradient19439) rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ <path d="M 523.53125,378.40625 L 523.53125,402.125 C 525.1769,402.07398 526.82304,402.09578 528.46875,402.125 L 528.46875,378.40625 L 523.53125,378.40625 z M 468.9375,393.40625 L 467.09375,394.46875 L 477.0625,411.75 C 477.67174,411.38819 478.28844,411.03612 478.90625,410.6875 L 468.9375,393.40625 z M 583.0625,393.40625 L 573.09375,410.6875 C 573.71156,411.03612 574.32826,411.38819 574.9375,411.75 L 584.90625,394.46875 L 583.0625,393.40625 z M 426.09375,435.46875 L 425.03125,437.3125 L 442.3125,447.28125 C 442.66112,446.66344 443.01319,446.04674 443.375,445.4375 L 426.09375,435.46875 z M 625.90625,435.46875 L 608.625,445.4375 C 608.98681,446.04674 609.33888,446.66344 609.6875,447.28125 L 626.96875,437.3125 L 625.90625,435.46875 z M 410.03125,491.90625 L 410.03125,496.84375 L 433.75,496.84375 C 433.69898,495.1981 433.72078,493.55196 433.75,491.90625 L 410.03125,491.90625 z M 618.25,491.90625 C 618.30102,493.5519 618.27922,495.19804 618.25,496.84375 L 641.96875,496.84375 L 641.96875,491.90625 L 618.25,491.90625 z M 442.3125,541.46875 L 425.03125,551.4375 L 426.09375,553.28125 L 443.375,543.3125 C 443.01319,542.70326 442.66112,542.08656 442.3125,541.46875 z M 609.6875,541.46875 C 609.33888,542.08656 608.98681,542.70326 608.625,543.3125 L 625.90625,553.28125 L 626.96875,551.4375 L 609.6875,541.46875 z M 477.0625,577 L 467.09375,594.28125 L 468.9375,595.34375 L 478.90625,578.0625 C 478.28844,577.71388 477.67174,577.36181 477.0625,577 z M 574.9375,577 C 574.32826,577.36181 573.71156,577.71388 573.09375,578.0625 L 583.0625,595.34375 L 584.90625,594.28125 L 574.9375,577 z M 523.53125,586.625 L 523.53125,610.34375 L 528.46875,610.34375 L 528.46875,586.625 C 526.8231,586.67602 525.17696,586.65422 523.53125,586.625 z" sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" id="path19427" style="fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ <path sodipodi:ry="138" sodipodi:rx="138" transform="matrix(0.728261, 0, 0, 0.601449, 306.065, 286.927)" d="M 440,288.36218 A 138,138 0 1 1 164,288.36218 A 138,138 0 1 1 440,288.36218 z" sodipodi:type="arc" sodipodi:cy="288.36218" sodipodi:cx="302" id="path19429" style="fill: url(#linearGradient19441) rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
+ </g>
+ </g>
+</svg>
diff --git a/data/icons/once_wrapper.svg b/data/icons/once_wrapper.svg
new file mode 100644
index 0000000..ad48720
--- /dev/null
+++ b/data/icons/once_wrapper.svg
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="48px"
+ height="48px"
+ id="svg2393"
+ sodipodi:version="0.32"
+ inkscape:version="0.46"
+ sodipodi:docname="once_wrapper.svg"
+ inkscape:output_extension="org.inkscape.output.svg.inkscape">
+ <defs
+ id="defs2395">
+ <inkscape:perspective
+ sodipodi:type="inkscape:persp3d"
+ inkscape:vp_x="0 : 24 : 1"
+ inkscape:vp_y="0 : 1000 : 0"
+ inkscape:vp_z="48 : 24 : 1"
+ inkscape:persp3d-origin="24 : 16 : 1"
+ id="perspective2401" />
+ </defs>
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="7"
+ inkscape:cx="24"
+ inkscape:cy="24"
+ inkscape:current-layer="layer1"
+ showgrid="true"
+ inkscape:grid-bbox="true"
+ inkscape:document-units="px"
+ inkscape:window-width="645"
+ inkscape:window-height="726"
+ inkscape:window-x="625"
+ inkscape:window-y="25" />
+ <metadata
+ id="metadata2398">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ id="layer1"
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer">
+ <flowRoot
+ xml:space="preserve"
+ id="flowRoot2403"
+ style="font-size:40px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;font-family:OpenSymbol;-inkscape-font-specification:OpenSymbol"
+ transform="matrix(1.2841451,0,0,1.2841451,-10.095321,-10.594455)"><flowRegion
+ id="flowRegion2405"><rect
+ id="rect2407"
+ width="45"
+ height="45"
+ x="1.8571428"
+ y="1.4285715"
+ style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:OpenSymbol;-inkscape-font-specification:OpenSymbol" /></flowRegion><flowPara
+ id="flowPara2409"> 1</flowPara></flowRoot> </g>
+</svg>
diff --git a/data/ui/creator.glade b/data/ui/creator.glade
new file mode 100644
index 0000000..1c9669d
--- /dev/null
+++ b/data/ui/creator.glade
@@ -0,0 +1,209 @@
+<?xml version="1.0"?>
+<glade-interface>
+ <!-- interface-requires gtk+ 2.16 -->
+ <!-- interface-naming-policy project-wide -->
+ <widget class="GtkWindow" id="mainwindow">
+ <property name="width_request">300</property>
+ <property name="height_request">500</property>
+ <property name="title" translatable="yes">Toolbox</property>
+ <property name="resizable">False</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">200</property>
+ <property name="default_height">500</property>
+ <property name="destroy_with_parent">True</property>
+ <property name="skip_taskbar_hint">True</property>
+ <property name="skip_pager_hint">True</property>
+ <property name="focus_on_map">False</property>
+ <property name="deletable">False</property>
+ <signal name="destroy" handler="on_mainwindow_destroy"/>
+ <child>
+ <widget class="GtkVBox" id="vbox1">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">5</property>
+ <child>
+ <widget class="GtkHButtonBox" id="hbuttonbox1">
+ <property name="visible">True</property>
+ <property name="spacing">5</property>
+ <property name="layout_style">start</property>
+ <child>
+ <widget class="GtkButton" id="button2">
+ <property name="label">gtk-save</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="on_save_clicked"/>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkButton" id="button4">
+ <property name="label">gtk-quit</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="on_quit_clicked"/>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkScrolledWindow" id="scrolledwindow1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">never</property>
+ <property name="vscrollbar_policy">automatic</property>
+ <property name="shadow_type">in</property>
+ <child>
+ <widget class="GtkViewport" id="viewport1">
+ <property name="visible">True</property>
+ <property name="resize_mode">queue</property>
+ <child>
+ <widget class="GtkVBox" id="vbox2">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <widget class="GtkExpander" id="expander1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <widget class="GtkIconView" id="iconview1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="columns">2</property>
+ <property name="row_spacing">0</property>
+ <property name="column_spacing">0</property>
+ <property name="item_padding">0</property>
+ <signal name="item_activated" handler="on_action_activate"/>
+ </widget>
+ </child>
+ <child>
+ <widget class="GtkLabel" id="label3">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">actions</property>
+ </widget>
+ <packing>
+ <property name="type">label_item</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkExpander" id="expander2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <widget class="GtkIconView" id="iconview2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="columns">2</property>
+ <property name="row_spacing">0</property>
+ <property name="column_spacing">0</property>
+ <property name="item_padding">0</property>
+ <signal name="item_activated" handler="on_event_activate"/>
+ </widget>
+ </child>
+ <child>
+ <widget class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">events</property>
+ </widget>
+ <packing>
+ <property name="type">label_item</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ </child>
+ </widget>
+ </child>
+ </widget>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkVBox" id="propbox">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">10</property>
+ <child>
+ <placeholder/>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="padding">5</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkHButtonBox" id="hbuttonbox2">
+ <property name="visible">True</property>
+ <property name="spacing">5</property>
+ <property name="layout_style">start</property>
+ <child>
+ <widget class="GtkButton" id="button1">
+ <property name="label">gtk-media-record</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkButton" id="button3">
+ <property name="label">gtk-media-stop</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ </widget>
+ </child>
+ </widget>
+</glade-interface>
diff --git a/setup.py b/setup.py
index 9362dc7..e33873c 100755
--- a/setup.py
+++ b/setup.py
@@ -93,13 +93,17 @@ setup(name='Tutorius',
'sugar.tutorius',
'sugar.tutorius.uam',
'sugar.tutorius.addons',
+ 'sugar.tutorius.apilib',
+ 'sugar.tutorius.apilib.httplib2',
],
package_dir={
'sugar.tutorius': 'tutorius',
'sugar.tutorius.addons': 'addons',
},
cmdclass = {'test': TestCommand},
- data_files=[('share/icons/sugar/scalable/actions', glob.glob('data/icons/*.svg')),]
+ data_files=[('share/icons/sugar/scalable/actions', glob.glob('data/icons/*.svg')),
+ ('share/tutorius/ui', glob.glob('data/ui/*.glade')),
+ ]
)
# vim: set et sw=4 sts=4 ts=4:
diff --git a/tests/actiontests.py b/tests/actiontests.py
index 4e126b3..7b8d1cb 100644
--- a/tests/actiontests.py
+++ b/tests/actiontests.py
@@ -25,6 +25,7 @@ import unittest
import gtk
from sugar.tutorius import addon
+from sugar.tutorius.addons.triggereventfilter import *
from sugar.tutorius.actions import *
from sugar.tutorius.services import ObjectStore
@@ -65,7 +66,7 @@ class DialogMessageTest(unittest.TestCase):
class BubbleMessageTest(unittest.TestCase):
def setUp(self):
- self.bubble = addon.create('BubbleMessage', message="Message text", pos=[200, 300], tailpos=[-15, -25])
+ self.bubble = addon.create('BubbleMessage', message="Message text", position=[200, 300], tail_pos=[-15, -25])
def test_properties(self):
props = self.bubble.get_properties()
@@ -115,7 +116,7 @@ class OnceWrapperTests(unittest.TestCase):
CountAction
"""
act = CountAction()
- wrap = OnceWrapper(act)
+ wrap = addon.create('OnceWrapper', act)
assert act.do_count == 0, "do() should not have been called in __init__()"
assert act.undo_count == 0, "undo() should not have been called in __init__()"
@@ -152,7 +153,7 @@ class ChainActionTest(unittest.TestCase):
def test_empty(self):
"""If the expected empty behavior (do nothing) changes
and starts throwing exceptions, this will flag it"""
- a = ChainAction()
+ a = addon.create('ChainAction')
a.do()
a.undo()
@@ -161,7 +162,7 @@ class ChainActionTest(unittest.TestCase):
first = ChainTester(witness)
second = ChainTester(witness)
- c = ChainAction(first, second)
+ c = addon.create('ChainAction', [first, second])
assert witness == [], "Actions should not be triggered on init"""
c.do()
@@ -194,13 +195,171 @@ class DisableWidgetActionTests(unittest.TestCase):
assert btn.props.sensitive is True, "Callback should have been called"
- act = DisableWidgetAction("0")
+ act = addon.create('DisableWidgetAction', "0")
assert btn.props.sensitive is True, "Callback should have been called again"
act.do()
assert btn.props.sensitive is False, "Callback should not have been called again"
act.undo()
assert btn.props.sensitive is True, "Callback should have been called again"
+class TrueWhileActiveAction(Action):
+ """
+ This action's active member is set to True after a do and to False after
+ an undo.
+
+ Used to verify that a State correctly triggers the do and undo actions.
+ """
+ def __init__(self):
+ Action.__init__(self)
+ self.active = False
+
+ def do(self):
+ self.active = True
+
+ def undo(self):
+ self.active = False
+
+class ClickableWidget():
+ """
+ This class fakes a widget with a clicked() method
+ """
+ def __init__(self):
+ self.click_count = 0
+
+ def clicked(self):
+ self.click_count += 1
+
+class FakeTextEntry():
+ """
+ This class fakes a widget with an insert_text() method
+ """
+ def __init__(self):
+ self.text_lines = []
+ self.last_entered_line = ""
+ self.displayed_text = ""
+
+ def insert_text(self, text, index):
+ self.last_entered_line = text
+ self.text_lines.append(text)
+ self.displayed_text = self.displayed_text[0:index] + text + self.displayed_text[index+1:]
+
+class FakeParentWidget():
+ """
+ This class fakes a widet container, it implements the get_children() method
+ """
+ def __init__(self):
+ self._children = []
+
+ def add_child(self, child):
+ self._children.append(child)
+
+ def get_children(self):
+ return self._children
+
+class FakeEventFilter(TriggerEventFilter):
+ """
+ This is a fake event that is connected to the tutorial.
+
+ The difference between this one and the TriggerEventFilter is that the
+ tutorial's set_state will be called on the callback.
+
+ Do not forget to add the do_callback() after creating the object.
+ """
+ def set_tutorial(self, tutorial):
+ self.tutorial = tutorial
+
+ def _inner_cb(self, event_filter):
+ self.toggle_on_callback = not self.toggle_on_callback
+ self.tutorial.set_state(event_filter.get_next_state())
+
+class TypeTextActionTests(unittest.TestCase):
+ """
+ Test class for type text action
+ """
+ def test_do_action(self):
+ activity = FakeParentWidget()
+ widget = FakeTextEntry()
+ activity.add_child(widget)
+ ObjectStore().activity = activity
+
+ test_text = "This is text"
+
+
+ action = addon.create('TypeTextAction', "0.0", test_text)
+
+ assert widget == ObjectStore().activity.get_children()[0],\
+ "The clickable widget isn't reachable from the object store \
+ the test cannot pass"
+
+ action.do()
+
+ assert widget.last_entered_line == test_text, "insert_text() should have been called by do()"
+
+ action.do()
+
+ assert widget.last_entered_line == test_text, "insert_text() should have been called by do()"
+ assert len(widget.text_lines) == 2, "insert_text() should have been called twice"
+
+ def test_undo(self):
+ activity = FakeParentWidget()
+ widget = FakeTextEntry()
+ activity.add_child(widget)
+ ObjectStore().activity = activity
+
+ test_text = "This is text"
+
+
+ action = addon.create('TypeTextAction', "0.0", test_text)
+
+ assert widget == ObjectStore().activity.get_children()[0],\
+ "The clickable widget isn't reachable from the object store \
+ the test cannot pass"
+
+ action.undo()
+
+ #There is no undo for this action so the test should not fail
+ assert True
+
+class ClickActionTests(unittest.TestCase):
+ """
+ Test class for click action
+ """
+ def test_do_action(self):
+ activity = FakeParentWidget()
+ widget = ClickableWidget()
+ activity.add_child(widget)
+ ObjectStore().activity = activity
+
+ action = addon.create('ClickAction', "0.0")
+
+ assert widget == ObjectStore().activity.get_children()[0],\
+ "The clickable widget isn't reachable from the object store \
+ the test cannot pass"
+
+ action.do()
+
+ assert widget.click_count == 1, "clicked() should have been called by do()"
+
+ action.do()
+
+ assert widget.click_count == 2, "clicked() should have been called by do()"
+
+ def test_undo(self):
+ activity = FakeParentWidget()
+ widget = ClickableWidget()
+ activity.add_child(widget)
+ ObjectStore().activity = activity
+
+ action = addon.create('ClickAction', "0.0")
+
+ assert widget == ObjectStore().activity.get_children()[0],\
+ "The clickable widget isn't reachable from the object store \
+ the test cannot pass"
+
+ action.undo()
+
+ #There is no undo for this action so the test should not fail
+ assert True
if __name__ == "__main__":
unittest.main()
diff --git a/tests/addontests.py b/tests/addontests.py
new file mode 100644
index 0000000..5fb4f61
--- /dev/null
+++ b/tests/addontests.py
@@ -0,0 +1,50 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Simon Poirier <simpoir@gmail.com>
+#
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import unittest
+
+from sugar.tutorius import addon
+
+class AddonTest(unittest.TestCase):
+ def test_create_constructor_fail(self):
+ try:
+ obj = addon.create("BubbleMessage", wrong_param=True, second_wrong="This", last_wrong=12, unknown=13.4)
+ assert False, "Constructor with wrong parameter should raise an exception"
+ except:
+ pass
+
+ def test_create_wrong_addon(self):
+ try:
+ obj = addon.create("Non existing addon name")
+ assert False, "Addon creator should raise an exception when the requested addon is unknown"
+ except:
+ pass
+
+ def test_create(self):
+ obj = addon.create("BubbleMessage", message="Hi!", position=[12,31])
+
+ assert obj is not None
+
+ def test_reload_addons(self):
+ addon._cache = None
+ assert len(addon.list_addons()) > 0, "Addons should be reloaded upon cache clear"
+
+ def test_get_addon_meta(self):
+ addon._cache = None
+ meta = addon.get_addon_meta("BubbleMessage")
+ assert meta.keys() == ['mandatory_props', 'class', 'display_name', 'name', 'icon',]
diff --git a/tests/bundlertests.py b/tests/bundlertests.py
deleted file mode 100644
index 8da2310..0000000
--- a/tests/bundlertests.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# Copyright (C) 2009, Tutorius.org
-# Copyright (C) 2009, Charles-Etienne Carriere <iso.swiffer@gmail.com>
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
-"""
-Bundler tests
-
-This module contains all the tests for the storage mecanisms for tutorials
-This mean testing savins and loading tutorial, .ini file management and
-adding ressources to tutorial
-"""
-
-import unittest
-import os
-import uuid
-
-from sugar.tutorius import bundler
-
-class TutorialBundlerTests(unittest.TestCase):
-
- def setUp(self):
-
- #generate a test GUID
- self.test_guid = uuid.uuid1()
- self.guid_path = os.path.join(bundler._get_store_root(),str(self.test_guid))
- os.mkdir(self.guid_path)
-
- self.ini_file = os.path.join(self.guid_path, "meta.ini")
-
- f = open(self.ini_file,'w')
- f.write("[GENERAL_METADATA]")
- f.write(os.linesep)
- f.write("GUID:")
- f.write(str(self.test_guid))
- f.close()
-
- def tearDown(self):
- os.remove(self.ini_file)
- os.rmdir(self.guid_path)
-
- def test_add_ressource(self):
- bund = bundler.TutorialBundler(self.test_guid)
-
- temp_file = open("test.txt",'w')
- temp_file.write('test')
- temp_file.close()
-
- bund.add_resource("test.txt")
-
- assert os.path.exists(os.path.join(self.guid_path,"test.txt")), "add_ressource did not create the file"
-
-if __name__ == "__main__":
- unittest.main() \ No newline at end of file
diff --git a/tests/constraintstests.py b/tests/constraintstests.py
index b7b0a47..a5ccf26 100644
--- a/tests/constraintstests.py
+++ b/tests/constraintstests.py
@@ -16,6 +16,9 @@
import unittest
+import uuid
+import os
+
from sugar.tutorius.constraints import *
class ConstraintTest(unittest.TestCase):
@@ -218,10 +221,18 @@ class EnumConstraintTest(unittest.TestCase):
assert False, "Wrong exception type thrown"
class FileConstraintTest(unittest.TestCase):
+ def setUp(self):
+ self.temp_filename = "sample_file_" + str(uuid.uuid1()) + ".txt"
+ self.file1 = file(self.temp_filename, "w")
+ self.file1.close()
+
+ def tearDown(self):
+ os.unlink(self.temp_filename)
+
def test_validate(self):
cons = FileConstraint()
- cons.validate("run-tests.py")
+ cons.validate(self.temp_filename)
try:
cons.validate("unknown/file.py")
@@ -229,5 +240,47 @@ class FileConstraintTest(unittest.TestCase):
except FileConstraintError:
pass
+class ResourceConstraintTest(unittest.TestCase):
+ def test_valid_names(self):
+ name1 = "file_" + unicode(uuid.uuid1()) + ".png"
+ name2 = unicode(uuid.uuid1()) + "_" + unicode(uuid.uuid1()) + ".extension"
+ name3 = "/home/user/.sugar/_random/new_image1231_" + unicode(uuid.uuid1()).upper() + ".mp3"
+ name4 = "a_" + unicode(uuid.uuid1())
+ name5 = ""
+
+ cons = ResourceConstraint()
+
+ # All of those names should pass without exceptions
+ cons.validate(name1)
+ cons.validate(name2)
+ cons.validate(name3)
+ cons.validate(name4)
+ cons.validate(name5)
+
+ def test_invalid_names(self):
+ bad_name1 = ".jpg"
+ bad_name2 = "_.jpg"
+ bad_name3 = "_" + unicode(uuid.uuid1())
+
+ cons = ResourceConstraint()
+
+ try:
+ cons.validate(bad_name1)
+ assert False, "%s should not be a valid resource name" % bad_name1
+ except ResourceConstraintError:
+ pass
+
+ try:
+ cons.validate(bad_name2)
+ assert False, "%s should not be a valid resource name" % bad_name2
+ except ResourceConstraintError:
+ pass
+
+ try:
+ cons.validate(bad_name3)
+ assert False, "%s should not be a valid resource name" % bad_name3
+ except ResourceConstraintError:
+ pass
+
if __name__ == "__main__":
- unittest.main() \ No newline at end of file
+ unittest.main()
diff --git a/tests/coretests.py b/tests/coretests.py
index eadea01..b9e04e5 100644
--- a/tests/coretests.py
+++ b/tests/coretests.py
@@ -28,13 +28,14 @@ and event filters. Those are in their separate test module
import unittest
+from copy import deepcopy
import logging
-from sugar.tutorius.actions import Action, OnceWrapper, ClickAction, TypeTextAction
+from sugar.tutorius.actions import *
+from sugar.tutorius.addon import *
from sugar.tutorius.core import *
from sugar.tutorius.filters import *
-
-from actiontests import CountAction
+from actiontests import CountAction, FakeEventFilter
# Helper classes to help testing
class SimpleTutorial(Tutorial):
@@ -49,6 +50,28 @@ class SimpleTutorial(Tutorial):
def set_state(self, name):
self.current_state_name = name
+class TutorialTest(unittest.TestCase):
+ """Tests the tutorial functions that are not covered elsewhere."""
+ def test_detach(self):
+ class Activity(object):
+ name = "this"
+
+ activity1 = Activity()
+ activity2 = Activity()
+
+ fsm = FiniteStateMachine("Sample example")
+
+ tutorial = Tutorial("Test tutorial", fsm)
+
+ assert tutorial.activity == None, "There is a default activity in the tutorial"
+
+ tutorial.attach(activity1)
+
+ assert tutorial.activity == activity1, "Activity should have been associated to this tutorial"
+
+ tutorial.attach(activity2)
+ assert tutorial.activity == activity2, "Activity should have been changed to activity2"
+
class TutorialWithFSM(Tutorial):
"""
Fake tutorial, but associated with a FSM.
@@ -73,173 +96,6 @@ class TrueWhileActiveAction(Action):
def undo(self):
self.active = False
-
-class ClickableWidget():
- """
- This class fakes a widget with a clicked() method
- """
- def __init__(self):
- self.click_count = 0
-
- def clicked(self):
- self.click_count += 1
-
-class FakeTextEntry():
- """
- This class fakes a widget with an insert_text() method
- """
- def __init__(self):
- self.text_lines = []
- self.last_entered_line = ""
- self.displayed_text = ""
-
- def insert_text(self, text, index):
- self.last_entered_line = text
- self.text_lines.append(text)
- self.displayed_text = self.displayed_text[0:index] + text + self.displayed_text[index+1:]
-
-class FakeParentWidget():
- """
- This class fakes a widet container, it implements the get_children() method
- """
- def __init__(self):
- self._children = []
-
- def add_child(self, child):
- self._children.append(child)
-
- def get_children(self):
- return self._children
-
-
-
-
-class TriggerEventFilter(EventFilter):
- """
- This event filter can be triggered by simply calling its do_callback function.
-
- Used to fake events and see the effect on the FSM.
- """
- def __init__(self, next_state):
- EventFilter.__init__(self, next_state)
- self.toggle_on_callback = False
-
- def install_handlers(self, callback, **kwargs):
- """
- Forsakes the incoming callback function and just set the inner one.
- """
- self._callback = self._inner_cb
-
- def _inner_cb(self, event_filter):
- self.toggle_on_callback = not self.toggle_on_callback
-
-class FakeEventFilter(TriggerEventFilter):
- """
- This is a fake event that is connected to the tutorial.
-
- The difference between this one and the TriggerEventFilter is that the
- tutorial's set_state will be called on the callback.
-
- Do not forget to add the do_callback() after creating the object.
- """
- def set_tutorial(self, tutorial):
- self.tutorial = tutorial
-
- def _inner_cb(self, event_filter):
- self.toggle_on_callback = not self.toggle_on_callback
- self.tutorial.set_state(event_filter.get_next_state())
-
-
-class ClickActionTests(unittest.TestCase):
- """
- Test class for click action
- """
- def test_do_action(self):
- activity = FakeParentWidget()
- widget = ClickableWidget()
- activity.add_child(widget)
- ObjectStore().activity = activity
-
- action = ClickAction("0.0")
-
- assert widget == ObjectStore().activity.get_children()[0],\
- "The clickable widget isn't reachable from the object store \
- the test cannot pass"
-
- action.do()
-
- assert widget.click_count == 1, "clicked() should have been called by do()"
-
- action.do()
-
- assert widget.click_count == 2, "clicked() should have been called by do()"
-
- def test_undo(self):
- activity = FakeParentWidget()
- widget = ClickableWidget()
- activity.add_child(widget)
- ObjectStore().activity = activity
-
- action = ClickAction("0.0")
-
- assert widget == ObjectStore().activity.get_children()[0],\
- "The clickable widget isn't reachable from the object store \
- the test cannot pass"
-
- action.undo()
-
- #There is no undo for this action so the test should not fail
- assert True
-
-
-
-class TypeTextActionTests(unittest.TestCase):
- """
- Test class for type text action
- """
- def test_do_action(self):
- activity = FakeParentWidget()
- widget = FakeTextEntry()
- activity.add_child(widget)
- ObjectStore().activity = activity
-
- test_text = "This is text"
-
-
- action = TypeTextAction("0.0", test_text)
-
- assert widget == ObjectStore().activity.get_children()[0],\
- "The clickable widget isn't reachable from the object store \
- the test cannot pass"
-
- action.do()
-
- assert widget.last_entered_line == test_text, "insert_text() should have been called by do()"
-
- action.do()
-
- assert widget.last_entered_line == test_text, "insert_text() should have been called by do()"
- assert len(widget.text_lines) == 2, "insert_text() should have been called twice"
-
- def test_undo(self):
- activity = FakeParentWidget()
- widget = FakeTextEntry()
- activity.add_child(widget)
- ObjectStore().activity = activity
-
- test_text = "This is text"
-
-
- action = TypeTextAction("0.0", test_text)
-
- assert widget == ObjectStore().activity.get_children()[0],\
- "The clickable widget isn't reachable from the object store \
- the test cannot pass"
-
- action.undo()
-
- #There is no undo for this action so the test should not fail
- assert True
# State testing class
class StateTest(unittest.TestCase):
@@ -274,9 +130,9 @@ class StateTest(unittest.TestCase):
Tests the fact that the event filters are correctly installed on setup
and uninstalled on teardown.
"""
- event_filter = TriggerEventFilter("second_state")
+ event_filter = addon.create('TriggerEventFilter')
- state = State("event_test", event_filter_list=[event_filter])
+ state = State("event_test", event_filter_list=[(event_filter, "second_state")])
state.set_tutorial(SimpleTutorial())
assert event_filter.toggle_on_callback == False, "Wrong init of event_filter"
@@ -326,14 +182,11 @@ class StateTest(unittest.TestCase):
assert state.add_action(act2), "Could not add the second action"
assert state.add_action(act3), "Could not add the third action"
- # Try to add a second time an action that was already inserted
- assert state.add_action(act1) == False, "Not supposed to insert an action twice"
-
# Fetch the associated actions
actions = state.get_action_list()
# Make sure all the actions are present in the state
- assert act1 in actions and act2 in actions and act3 in actions,\
+ assert act1 in actions and act2 in actions and act3 in actions, \
"The actions were not properly inserted in the state"
# Clear the list
@@ -345,22 +198,21 @@ class StateTest(unittest.TestCase):
def test_add_event_filter(self):
state = State("INIT")
- event1 = TriggerEventFilter("s")
- event2 = TriggerEventFilter("t")
- event3 = TriggerEventFilter("r")
+ event1 = addon.create('TriggerEventFilter')
+ event2 = addon.create('TriggerEventFilter')
# Insert the event filters
- assert state.add_event_filter(event1), "Could not add event filter 1"
- assert state.add_event_filter(event2), "Could not add event filter 2"
- assert state.add_event_filter(event3), "Could not add event filter 3"
+ assert state.add_event_filter(event1, "s"), "Could not add event filter 1"
# Make sure we cannot insert an event twice
- assert state.add_event_filter(event1) == False, "Could add twice the event filter"
+ assert state.add_event_filter(event1, "s") == False, "Could add twice the event filter"
+ assert state.add_event_filter(event2, "t") == False, "Could add event filter 2"
# Get the list of event filters
- event_filters = state.get_event_filter_list()
+ event_filters = map(lambda x: x[0],state.get_event_filter_list())
- assert event1 in event_filters and event2 in event_filters and event3 in event_filters, \
+ #even if we added only the event 1, they are equivalent
+ assert event1 in event_filters and event2 in event_filters, \
"The event filters were not all added inside the state"
# Clear the list
@@ -368,7 +220,79 @@ class StateTest(unittest.TestCase):
assert len(state.get_event_filter_list()) == 0, \
"Could not clear the event filter list properly"
+
+ def test_eq_simple(self):
+ """
+ Two empty states with the same name must be identical
+ """
+ st1 = State("Identical")
+ st2 = State("Identical")
+
+ assert st1 == st2, "Empty states with the same name should be identical"
+
+ def test_eq(self):
+ """
+ Test whether two states share the same set of actions and event filters.
+ """
+ st1 = State("Identical")
+ st2 = State("Identical")
+
+ non_state = object()
+
+ act1 = addon.create("BubbleMessage", message="Hi", position=[132,450])
+ act2 = addon.create("BubbleMessage", message="Hi", position=[132,450])
+
+ event1 = addon.create("GtkWidgetEventFilter", "0.0.0.1.1.2.3.1", "clicked")
+
+ act3 = addon.create("DialogMessage", message="Hello again.", position=[200, 400])
+
+ # Build the first state
+ st1.add_action(act1)
+ st1.add_action(act3)
+ st1.add_event_filter(event1, "nextState")
+
+ # Build the second state
+ st2.add_action(act2)
+ st2.add_action(act3)
+ st2.add_event_filter(event1, "nextState")
+
+ # Make sure that they are identical for now
+ assert st1 == st2, "States should be considered as identical"
+ assert st2 == st1, "States should be considered as identical"
+ # Modify the second bubble message action
+ act2.message = "New message"
+
+ # Since one action changed in the second state, this should indicate that the states
+ # are not identical anymore
+ assert not (st1 == st2), "Action was changed and states should be different"
+ assert not (st2 == st1), "Action was changed and states should be different"
+
+ # Make sure that trying to find identity with something else than a State object fails properly
+ assert not (st1 == non_state), "Passing a non-State object should fail for identity"
+
+ st2.name = "Not identical anymore"
+ assert not(st1 == st2), "Different state names should give different states"
+ st2.name = "Identical"
+
+ st3 = deepcopy(st1)
+ st3.add_action(addon.create("BubbleMessage", "Hi!", [128,264]))
+
+ assert not (st1 == st3), "States having a different number of actions should be different"
+
+ st4 = deepcopy(st1)
+ st4.add_event_filter(addon.create("GtkWidgetEventFilter", "0.0.1.1.2.2.3", "clicked"), "next_state")
+
+ assert not (st1 == st4), "States having a different number of events should be different"
+
+ st5 = deepcopy(st1)
+ st5._event_filters = []
+
+ st5.add_event_filter(addon.create("GtkWidgetEventFilter", "0.1.2.3.4.1.2", "pressed"), "other_state")
+
+ assert not (st1 == st5), "States having the same number of event filters" \
+ + " but those being different should be different"
+
class FSMTest(unittest.TestCase):
"""
This class needs to text the interface and functionality of the Finite
@@ -379,10 +303,10 @@ class FSMTest(unittest.TestCase):
act_init = TrueWhileActiveAction()
act_second = TrueWhileActiveAction()
- event_init = FakeEventFilter("SECOND")
+ event_init = FakeEventFilter()
content = {
- "INIT": State("INIT", action_list=[act_init],event_filter_list=[event_init]),
+ "INIT": State("INIT", action_list=[act_init],event_filter_list=[(event_init,"SECOND")]),
"SECOND": State("SECOND", action_list=[act_second])
}
@@ -413,6 +337,7 @@ class FSMTest(unittest.TestCase):
assert act_second.active == False, "FSM did not teardown SECOND properly"
+
def test_state_insert(self):
"""
This is a simple test to insert, then find a state.
@@ -472,9 +397,9 @@ class FSMTest(unittest.TestCase):
This test removes a state from the FSM. It also verifies that the links
from other states going into the removed state are gone.
"""
- st1 = State("INIT", event_filter_list=[TriggerEventFilter("second")])
- st2 = State("second", event_filter_list=[TriggerEventFilter("third")])
- st3 = State("third", event_filter_list=[TriggerEventFilter("second")])
+ st1 = State("INIT", event_filter_list=[(addon.create('TriggerEventFilter'), "second")])
+ st2 = State("second", event_filter_list=[(addon.create('TriggerEventFilter'), "third")])
+ st3 = State("third", event_filter_list=[(addon.create('TriggerEventFilter'), "second")])
fsm = FiniteStateMachine("StateRemovalTest")
@@ -504,10 +429,10 @@ class FSMTest(unittest.TestCase):
# Make sure that there is no link to the removed state in the rest
# of the FSM
- assert "second" not in fsm.get_following_states("INIT"),\
+ assert "second" not in fsm.get_following_states("INIT"), \
"The link to second from INIT still exists after removal"
- assert "second" not in fsm.get_following_states("third"),\
+ assert "second" not in fsm.get_following_states("third"), \
"The link to second from third still exists after removal"
def test_set_same_state(self):
@@ -534,8 +459,116 @@ class FSMTest(unittest.TestCase):
"The action was triggered a second time, do_count = %d"%do_count
undo_count = fsm.get_state_by_name("INIT").get_action_list()[0].undo_count
- assert fsm.get_state_by_name("INIT").get_action_list()[0].undo_count == 0,\
+ assert fsm.get_state_by_name("INIT").get_action_list()[0].undo_count == 0, \
"The action has been undone unappropriately, undo_count = %d"%undo_count
+
+ def test_setup(self):
+ fsm = FiniteStateMachine("New state machine")
+
+ try:
+ fsm.setup()
+ assert False, "fsm should throw an exception when trying to setup and not bound to a tutorial"
+ except UnboundLocalError:
+ pass
+
+ def test_setup_actions(self):
+ tut = SimpleTutorial()
+
+ states_dict = {"INIT": State("INIT")}
+ fsm = FiniteStateMachine("New FSM", state_dict=states_dict)
+
+ act = CountAction()
+ fsm.add_action(act)
+
+ fsm.set_tutorial(tut)
+
+ fsm.setup()
+
+ # Let's also test the current state name
+ assert fsm.get_current_state_name() == "INIT", "Initial state should be INIT"
+
+ assert act.do_count == 1, "Action should have been called during setup"
+
+ fsm._fsm_has_finished = True
+
+ fsm.teardown()
+
+ assert act.undo_count == 1, "Action should have been undone"
+
+ def test_string_rep(self):
+ fsm = FiniteStateMachine("Testing machine")
+
+ st1 = State("INIT")
+ st2 = State("Other State")
+ st3 = State("Final State")
+
+ st1.add_action(addon.create("BubbleMessage", "Hi!", [132,312]))
+
+ fsm.add_state(st1)
+ fsm.add_state(st2)
+ fsm.add_state(st3)
+
+ assert str(fsm) == "INIT, Final State, Other State, "
+
+ def test_eq_(self):
+ fsm = FiniteStateMachine("Identity test")
+
+ non_fsm_object = object()
+
+ assert not (fsm == non_fsm_object), "Testing with non FSM object should not give identity"
+
+ # Compare FSMs
+ act1 = CountAction()
+
+ fsm.add_action(act1)
+
+ fsm2 = deepcopy(fsm)
+
+ assert fsm == fsm2
+
+ act2 = CountAction()
+ fsm2.add_action(act2)
+
+ assert not(fsm == fsm2), \
+ "FSMs having a different number of actions should be different"
+
+ fsm3 = FiniteStateMachine("Identity test")
+
+ act3 = addon.create("BubbleMessage", "Hi!", [123,312])
+ fsm3.add_action(act3)
+
+ assert not(fsm3 == fsm), \
+ "Actions having the same number of actions but different ones should be different"
+
+ st1 = State("INIT")
+
+ st2 = State("OtherState")
+
+ fsm.add_state(st1)
+ fsm.add_state(st2)
+
+ fsm4 = deepcopy(fsm)
+
+ assert fsm == fsm4
+
+ st3 = State("Last State")
+
+ fsm4.add_state(st3)
+
+ assert not (fsm == fsm4), "FSMs having a different number of states should not be identical"
+
+ fsm4.remove_state("OtherState")
+
+ assert not (fsm == fsm4), "FSMs having different states should be different"
+
+ fsm4.remove_state("Last State")
+
+ st5 = State("OtherState")
+ st5.add_action(CountAction())
+
+ fsm4.add_state(st5)
+
+ assert not(fsm == fsm4), "FSMs having states with same name but different content should be different"
class FSMExplorationTests(unittest.TestCase):
def setUp(self):
@@ -547,13 +580,13 @@ class FSMExplorationTests(unittest.TestCase):
"""
st1 = State("INIT")
st1.add_action(CountAction())
- st1.add_event_filter(TriggerEventFilter("Second"))
- st1.add_event_filter(TriggerEventFilter("Third"))
+ st1.add_event_filter(addon.create('TriggerEventFilter'), "Second")
+ st1.add_event_filter(addon.create('TriggerEventFilter'), "Third")
st2 = State("Second")
st2.add_action(TrueWhileActiveAction())
- st2.add_event_filter(TriggerEventFilter("Third"))
- st2.add_event_filter(TriggerEventFilter("Fourth"))
+ st2.add_event_filter(addon.create('TriggerEventFilter'), "Third")
+ st2.add_event_filter(addon.create('TriggerEventFilter'), "Fourth")
st3 = State("Third")
st3.add_action(CountAction())
@@ -592,6 +625,5 @@ class FSMExplorationTests(unittest.TestCase):
self.validate_previous_states("Fourth", ("Second"))
-
if __name__ == "__main__":
unittest.main()
diff --git a/tests/enginetests.py b/tests/enginetests.py
new file mode 100644
index 0000000..60a68f4
--- /dev/null
+++ b/tests/enginetests.py
@@ -0,0 +1,30 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import unittest
+
+import dbus
+
+session_bus = dbus.SessionBus()
+
+ENGINE_BUS_NAME = "org.tutorius.engine"
+
+LAUNCH_PATH = "/launch"
+STOP_PATH = "/stop"
+PAUSE_PATH = "/pause"
+
+class EngineInterfaceTests(unittest.TestCase):
+ pass
diff --git a/tests/filterstests.py b/tests/filterstests.py
index 3e79bcc..ee6033b 100644
--- a/tests/filterstests.py
+++ b/tests/filterstests.py
@@ -26,26 +26,16 @@ import time
import gobject
import gtk
-from sugar.tutorius.filters import EventFilter, TimerEvent, GtkWidgetTypeFilter
+from sugar.tutorius.filters import EventFilter
from sugar.tutorius import addon
from gtkutilstests import SignalCatcher
class BaseEventFilterTests(unittest.TestCase):
"""Test the behavior of the Base EventFilter class"""
- def test_properties(self):
- """Test EventFilter properties"""
- e = EventFilter("NEXTSTATE")
-
- assert e.next_state == "NEXTSTATE", "next_state should have value used in constructor"
-
- e.next_state = "NEWSTATE"
-
- assert e.next_state == "NEWSTATE", "next_state should have been changed by setter"
-
def test_callback(self):
"""Test the callback mechanism"""
- e = EventFilter("Next")
+ e = EventFilter()
s = SignalCatcher()
#Trigger the do_callback, shouldn't do anything
@@ -79,7 +69,7 @@ class TestTimerEvent(unittest.TestCase):
ctx = gobject.MainContext()
main = gobject.MainLoop(ctx)
- e = TimerEvent("Next",1) #1 second should be enough :s
+ e = addon.create('TimerEvent', 2) # 2 seconds should be enough :s
s = SignalCatcher()
e.install_handlers(s.callback)
@@ -122,7 +112,7 @@ class TestTimerEvent(unittest.TestCase):
ctx = gobject.MainContext()
main = gobject.MainLoop(ctx)
- e = TimerEvent("Next",1) #1 second should be enough :s
+ e = addon.create('TimerEvent', 2) # 2 seconds should be enough :s
s = SignalCatcher()
e.install_handlers(s.callback)
@@ -169,7 +159,7 @@ class TestGtkWidgetEventFilter(unittest.TestCase):
self.top.add(self.btn1)
def test_install(self):
- h = addon.create('GtkWidgetEventFilter', "Next","0","whatever")
+ h = addon.create('GtkWidgetEventFilter', "0","whatever")
try:
h.install_handlers(None)
@@ -178,7 +168,7 @@ class TestGtkWidgetEventFilter(unittest.TestCase):
assert True, "Install should have failed"
def test_button_clicks(self):
- h = addon.create('GtkWidgetEventFilter', "Next","0.0","clicked")
+ h = addon.create('GtkWidgetEventFilter', "0.0","clicked")
s = SignalCatcher()
h.install_handlers(s.callback, activity=self.top)
diff --git a/tests/inject.py b/tests/inject.py
new file mode 100644
index 0000000..d69d6ff
--- /dev/null
+++ b/tests/inject.py
@@ -0,0 +1,57 @@
+#Test event injection
+
+import gtk
+import gobject
+import time
+import types
+
+class ClickMaster():
+ def __init__(self):
+ self.event = None
+
+ def connect(self, button):
+ self.id = button.connect("pressed",self.capture_event)
+ self.id2 = button.connect("released",self.capture_event2)
+ self.id3 = button.connect("clicked",self.capture_event3)
+ self.button = button
+
+ def capture_event(self, *args):
+ print "Capture Event"
+ print args
+ self.eventPress = args[-1]
+ return False
+
+ def capture_event2(self, *args):
+ print "Capture Release"
+ print args
+ self.eventReleased = args[-1]
+ return False
+
+ def capture_event3(self, *args):
+ print "Capture Clicked"
+ print args
+ self.eventClicked = args[-1]
+ return False
+
+ def inject_event(self):
+ print "Injecting"
+ print self.event
+ #self.event.put()
+ self.button.emit("button_press_event", self.event)
+
+def print_Event(event):
+ for att in dir(event):
+ if not isinstance(att, types.MethodType):
+ print att, getattr(event, att)
+
+if __name__=='__main__':
+ w = gtk.Window()
+ b = gtk.CheckButton("Auto toggle!")
+ c=ClickMaster()
+ w.add(b)
+ b.show()
+ c.connect(b)
+
+ w.show()
+
+ gtk.main()
diff --git a/tests/linear_creatortests.py b/tests/linear_creatortests.py
index dcded57..e3c30c1 100644
--- a/tests/linear_creatortests.py
+++ b/tests/linear_creatortests.py
@@ -19,7 +19,7 @@ from sugar.tutorius.core import *
from sugar.tutorius.actions import *
from sugar.tutorius.filters import *
from sugar.tutorius.linear_creator import *
-from coretests import TriggerEventFilter
+from sugar.tutorius.addons.triggereventfilter import *
from actiontests import CountAction
import unittest
@@ -35,11 +35,11 @@ class CreatorTests(unittest.TestCase):
creator.action(CountAction())
creator.action(CountAction())
- creator.event(TriggerEventFilter("Not important"))
+ creator.event(TriggerEventFilter())
creator.action(CountAction())
- creator.event(TriggerEventFilter("Not good either..."))
+ creator.event(TriggerEventFilter())
fsm = creator.generate_fsm()
@@ -50,13 +50,13 @@ class CreatorTests(unittest.TestCase):
assert len(init_state.get_action_list()) == 2, "Creator did not insert all the actions"
- assert init_state.get_event_filter_list()[0].get_next_state() == "State 1" , "expected next state to be 'State 1' but got %s" % init_state.get_event_filter_list()[0].get_next_state()
+ assert init_state.get_event_filter_list()[0][1] == "State 1" , "expected next state to be 'State 1' but got %s" % init_state.get_event_filter_list()[0][1]
state1 = fsm.get_state_by_name("State 1")
assert len(state1.get_action_list()) == 1, "Creator did not insert all the actions"
- assert state1.get_event_filter_list()[0].get_next_state() == "State 2"
+ assert state1.get_event_filter_list()[0][1] == "State 2"
# Make sure we have the final state and that it's empty
state2 = fsm.get_state_by_name("State2")
diff --git a/tests/probetests.py b/tests/probetests.py
index a440334..977eda9 100644
--- a/tests/probetests.py
+++ b/tests/probetests.py
@@ -19,45 +19,477 @@ Probe Tests
"""
import unittest
-import os, sys
-import gtk
-import time
+import pickle
from dbus.mainloop.glib import DBusGMainLoop
+from dbus.mainloop import NULL_MAIN_LOOP
import dbus
-from sugar.tutorius.TProbe import TProbe, ProbeProxy
+from sugar.tutorius.TProbe import TProbe, ProbeProxy, ProbeManager
+from sugar.tutorius import addon
+from sugar.tutorius.actions import Action
+from sugar.tutorius.properties import TIntProperty, TStringProperty
-class FakeActivity(object):
- def __init__(self):
- self.top = gtk.Window(type=gtk.WINDOW_TOPLEVEL)
- self.top.set_name("Top")
+#Create a substitute addon create function
+old_addon_create = addon.create
+fake_addon_cache = {}
+def new_addon_create(name, *args, **kwargs):
+ if name in fake_addon_cache:
+ return fake_addon_cache[name](*args, **kwargs)
+ else:
+ return old_addon_create(name, *args, **kwargs)
+
+message_box = None
+event_box = None
+
+class MockAddon(Action):
+ i = TIntProperty(0)
+ s = TStringProperty("test")
+
+ def do(self):
+ global message_box
+ message_box = (self.i, self.s)
+
+ def undo(self):
+ global message_box
+ message_box = None
+
+ def install_handlers(self, callback, **kwargs):
+ global message_box
+ message_box = callback
+
+ def remove_handlers(self):
+ global message_box
+ message_box = None
+
+fake_addon_cache["MockAddon"] = MockAddon
+
+class MockActivity(object):
+ def get_bundle_id(self):
+ return "localhost.unittest.ProbeTest"
+
+ def get_id(self):
+ return "unique_id_1"
+
+
+class MockProbeProxy(object):
+ _MockProxyCache = {}
+ def __new__(cls, activityName, unique_id):
+ #For testing, use only one instance per activityName
+ return cls._MockProxyCache.setdefault(activityName, super(MockProbeProxy, cls).__new__(cls))
+
+ def __init__(self, activityName, unique_id):
+ """
+ Constructor
+ @param activityName unique activity id. Must be a valid dbus bus name.
+ """
+ self.MockAction = None
+ self.MockActionUpdate = None
+ self.MockEvent = None
+ self.MockCB = None
+ self.MockAlive = True
+ self.MockEventAddr = None
- hbox = gtk.HBox()
- self.top.add(hbox)
- hbox.show()
+ def isAlive(self):
+ return self.MockAlive
+
+ def install(self, action, block=False):
+ self.MockAction = action
+ self.MockActionUpdate = None
+ return None
+
+ def update(self, action, newaction, block=False):
+ self.MockAction = action
+ self.MockActionUpdate = newaction
+ return None
+
+ def uninstall(self, action, block=False):
+ self.MockAction = None
+ self.MockActionUpdate = None
+ return None
+
+ def subscribe(self, event, callback, block=True):
+ #Do like the current Probe
+ if not block:
+ raise RuntimeError("This function does not allow non-blocking mode yet")
+
+ self.MockEvent= event
+ self.MockCB = callback
+ return str(id(event))
+
+ def unsubscribe(self, address, block=True):
+ self.MockEventAddr = address
+ return None
+
+ def detach(self, block=False):
+ self.MockAction = None
+ self.MockActionUpdate = None
+ self.MockEvent = None
+ self.MockCB = None
+ self.MockAlive = False
+ self.MockEventAddr = None
+ return None
+
+class MockProxyObject(object):
+ _MockProxyObjects = {}
+ def __new__(cls, name, path):
+ return cls._MockProxyObjects.setdefault((name, path), super(MockProxyObject, cls).__new__(cls))
+
+ def __init__(self, name, path):
+ self.MockCall = {}
+ self.MockRet = {}
+ self.MockCB = {}
+
+ def get_dbus_method(self, name, *args, **kwargs):
+ #FIXME This mockMethod should support asynchronous calling,
+ # and possibly more
+ def mockMethod(*a, **kw):
+ self.MockCall[name] = dict(args=a, kwargs=kw)
+ return self.MockRet.get(name, None)
+ return mockMethod
+
+ def connect_to_signal(self, signal_name, handler_function, dbus_interface=None, **kw):
+ self.MockCB[signal_name] = dict(handler_function=handler_function, dbus_interface=dbus_interface, **kw)
+
+class MockSessionBus(object):
+ def get_object(self, bus_name, object_path, introspect=True, follow_name_owner_changes=False, **kwargs):
+ return MockProxyObject(bus_name, object_path)
+
+old_SessionBus = dbus.SessionBus
- btn1 = gtk.Button()
- btn1.set_name("Button1")
- hbox.pack_start(btn1)
- btn1.show()
- self.button = btn1
+class MockServiceProxy(object):
+ def register_probe(self, process_name, unique_id):
+ pass
+ def unregister_probe(self, unique_id):
+ pass
+###########################################################################
+# Begin Test Cases
+###########################################################################
class ProbeTest(unittest.TestCase):
- def test_ping(self):
+ def setUp(self):
+ global message_box
+ message_box = None
+
+ #Fix the addon create
+ addon.create = new_addon_create
+
+ #Set a default dbus mainloop
m = DBusGMainLoop(set_as_default=True)
dbus.set_default_main_loop(m)
- activity = FakeActivity()
- probe = TProbe("localhost.unittest.ProbeTest", activity.top)
+ #Setup the activity and probe
+ self.activity = MockActivity()
+ self.probe = TProbe(self.activity, MockServiceProxy())
- #Parent, ping the probe
- proxy = ProbeProxy("localhost.unittest.ProbeTest")
- res = probe.ping()
-
+ #Override the eventOccured on the Probe...
+ self.old_eO = self.probe.eventOccured
+ def newEo(event):
+ global event_box
+ try:
+ self.old_eO(event)
+ event_box = event
+ except RuntimeError:
+ event_box = None
+
+ self.probe.eventOccured = newEo
+
+ def tearDown(self):
+ #Replace addon create
+ addon.create = old_addon_create
+
+ #Clear the default dbus mainloop
+ dbus.set_default_main_loop(NULL_MAIN_LOOP)
+
+ #Clear the activity
+ self.probe.remove_from_connection()
+ del self.probe
+ del self.activity
+
+ def test_ping(self):
+ #Test ping()
+ res = self.probe.ping()
assert res == "alive", "Probe should be alive"
+ def test_action(self):
+ global message_box
+ action = MockAddon()
+ action.i, action.s = (5,"woot")
+
+ assert message_box is None, "Message box should still be empty"
+
+ #install 1
+ address = self.probe.install(pickle.dumps(action))
+ assert type(address) == str, "install should return a string"
+ assert message_box == (5, "woot"), "message box should have (i, s)"
+
+ #install 2
+ action.i, action.s = (10, "ahhah!")
+ address2 = self.probe.install(pickle.dumps(action))
+ assert message_box == (10, "ahhah!"), "message box should have changed"
+ assert address != address2, "action addresses should be different"
+
+ #uninstall 2
+ self.probe.uninstall(address2)
+ assert message_box is None, "undo should clear the message box"
+
+ #update action 1 with action 2 props
+ self.probe.update(address, pickle.dumps(action._props))
+ assert message_box == (10, "ahhah!"), "message box should have changed(i, s)"
+
+ #ErrorCase: Update with bad address
+ #try to update 2, should fail
+ self.assertRaises(KeyError, self.probe.update, address2, pickle.dumps(action._props))
+
+ self.probe.uninstall(address)
+ assert message_box is None, "undo should clear the message box"
+
+ message_box = "Test"
+ #ErrorCase: Uninstall bad address (currently silent fail)
+ #Uninstall twice should do nothing
+ self.probe.uninstall(address)
+ assert message_box == "Test", "undo should not have happened again"
+
+ def test_events(self):
+ global message_box
+ global event_box
+
+ event = MockAddon()
+ event.i, event.s = (0, "event1")
+ event2 = MockAddon()
+ event2.i, event2.s = (1, "event2")
+
+ addr = self.probe.subscribe(pickle.dumps(event))
+ cb1 = message_box
+ addr2 = self.probe.subscribe(pickle.dumps(event2))
+ cb2 = message_box
+ assert type(addr) == str, "should return a string address"
+ assert addr != addr2, "each subscribe should return a different address"
+
+ assert event_box is None, "event_box should still be empty"
+ #Do the callback 2
+ cb2()
+
+ assert event_box is not None, "event_box should have an event"
+
+ assert type(event_box) == str, "event should be pickled"
+ assert pickle.loads(event_box) == event2, "event should be event2"
+
+ #Unsubscribe event 2
+ self.probe.unsubscribe(addr2)
+ assert message_box is None, "unsubscribe should clear the message_box"
+
+ #Do the callback 1
+ cb1()
+ assert pickle.loads(event_box) == event, "event should be event1"
+
+ #unsubscribe event 1
+ self.probe.unsubscribe(addr)
+ assert message_box is None, "unsubscribe should clear the message_box"
+
+ event_box = None
+ #ErrorCase: callback called from unregistered event filter
+ #Do the callback 1 again
+ self.assertRaises(RuntimeWarning, cb1)
+
+class ProbeManagerTest(unittest.TestCase):
+ def setUp(self):
+ MockProbeProxy._MockProxyCache = {}
+ self.probeManager = ProbeManager(proxy_class=MockProbeProxy)
+
+ def test_register_probe(self):
+ assert len(self.probeManager.get_registered_probes_list()) == 0
+
+ self.probeManager.register_probe("act1", "unique_id_1")
+ assert len(self.probeManager.get_registered_probes_list()) == 1
+ assert len(self.probeManager.get_registered_probes_list("act1")) == 1
+ assert self.probeManager.get_registered_probes_list()[0][0] == "unique_id_1"
+
+ self.probeManager.register_probe("act2","unique_id_2")
+ assert len(self.probeManager.get_registered_probes_list()) == 2
+ assert len(self.probeManager.get_registered_probes_list("act1")) == 1
+ assert self.probeManager.get_registered_probes_list("act1")[0][0] == "unique_id_1"
+ assert len(self.probeManager.get_registered_probes_list("act2")) == 1
+ assert self.probeManager.get_registered_probes_list("act2")[0][0] == "unique_id_2"
+
+ def test_register_multiple_probes(self):
+ assert len(self.probeManager.get_registered_probes_list()) == 0
+
+ self.probeManager.register_probe("act1", "unique_id_1")
+ self.probeManager.register_probe("act1","unique_id_2")
+ assert len(self.probeManager.get_registered_probes_list()) == 2
+ assert len(self.probeManager.get_registered_probes_list("act1")) == 2
+ assert self.probeManager.get_registered_probes_list("act1")[0][0] == "unique_id_1"
+ assert self.probeManager.get_registered_probes_list("act1")[1][0] == "unique_id_2"
+
+ def test_unregister_probe(self):
+ assert len(self.probeManager.get_registered_probes_list()) == 0
+ self.probeManager.register_probe("act1", "unique_id_1")
+ self.probeManager.register_probe("act1","unique_id_2")
+
+ self.probeManager.unregister_probe("unique_id_1")
+ assert len(self.probeManager.get_registered_probes_list("act1")) == 1
+ assert self.probeManager.get_registered_probes_list("act1")[0][0] == "unique_id_2"
+
+ self.probeManager.unregister_probe("unique_id_2")
+ assert len(self.probeManager.get_registered_probes_list("act1")) == 0
+ assert self.probeManager.get_registered_probes_list("act1") == []
+
+ def test_actions(self):
+ self.probeManager.register_probe("act1", "unique_id_1")
+ self.probeManager.register_probe("act2", "unique_id_2")
+ act1 = self.probeManager.get_registered_probes_list("act1")[0][1]
+ act2 = self.probeManager.get_registered_probes_list("act2")[0][1]
+
+ ad1 = MockAddon()
+ #ErrorCase: install, update, uninstall without currentActivity
+ #Action functions should do a warning if there is no activity
+ self.assertRaises(RuntimeWarning, self.probeManager.install, ad1)
+ self.assertRaises(RuntimeWarning, self.probeManager.update, ad1, ad1)
+ self.assertRaises(RuntimeWarning, self.probeManager.uninstall, ad1)
+ assert act1.MockAction is None, "Action should not be installed on inactive proxy"
+ assert act2.MockAction is None, "Action should not be installed on inactive proxy"
+
+ self.probeManager.currentActivity = "act1"
+ self.probeManager.install(ad1)
+ assert act1.MockAction == ad1, "Action should have been installed"
+ assert act2.MockAction is None, "Action should not be installed on inactive proxy"
+
+ self.probeManager.update(ad1, ad1)
+ assert act1.MockActionUpdate == ad1, "Action should have been updated"
+ assert act2.MockActionUpdate is None, "Should not update on inactive"
+
+ self.probeManager.currentActivity = "act2"
+ self.probeManager.uninstall(ad1)
+ assert act1.MockAction == ad1, "Action should still be installed"
+
+ self.probeManager.currentActivity = "act1"
+ self.probeManager.uninstall(ad1)
+ assert act1.MockAction is None, "Action should be uninstalled"
+
+ def test_events(self):
+ self.probeManager.register_probe("act1", "unique_id_1")
+ self.probeManager.register_probe("act2", "unique_id_2")
+ act1 = self.probeManager.get_registered_probes_list("act1")[0][1]
+ act2 = self.probeManager.get_registered_probes_list("act2")[0][1]
+
+ ad1 = MockAddon()
+ ad2 = MockAddon()
+ ad2.i, ad2.s = (2, "test2")
+
+ cb1 = lambda *args: None
+ cb2 = lambda *args: None
+
+ #ErrorCase: unsubscribe and subscribe without current activity
+ #Event functions should do a warning if there is no activity
+ self.assertRaises(RuntimeWarning, self.probeManager.subscribe, ad1, cb1)
+ self.assertRaises(RuntimeWarning, self.probeManager.unsubscribe, None)
+ assert act1.MockEvent is None, "No event should be on act1"
+ assert act2.MockEvent is None, "No event should be on act2"
+
+ self.probeManager.currentActivity = "act1"
+ self.probeManager.subscribe(ad1, cb1)
+ assert act1.MockEvent == ad1, "Event should have been installed"
+ assert act1.MockCB == cb1, "Callback should have been set"
+ assert act2.MockEvent is None, "No event should be on act2"
+
+ self.probeManager.unsubscribe("SomeAddress")
+ assert act1.MockEventAddr == "SomeAddress", "Unsubscribe should have been called"
+ assert act2.MockEventAddr is None, "Unsubscribe should not have been called"
+
+
+class ProbeProxyTest(unittest.TestCase):
+ def setUp(self):
+ dbus.SessionBus = MockSessionBus
+
+ self.mockObj = MockProxyObject("unittest.TestCase", "/tutorius/Probe/unique_id_1")
+ self.probeProxy = ProbeProxy("unittest.TestCase", "unique_id_1")
+
+ def tearDown(self):
+ dbus.SessionBus = old_SessionBus
+ MockProxyObject._MockProxyObjects = {}
+ # TODO: Clean-Up the dbus session bus ???
+
+ def test_Alive(self):
+ self.mockObj.MockRet["ping"] = "alive"
+ assert self.probeProxy.isAlive() == True, "Alive should return True"
+
+ self.mockObj.MockRet["ping"] = "anything else"
+ assert self.probeProxy.isAlive() == False, "Alive should return False"
+
+ def test_actions(self):
+ action = MockAddon()
+ action.i, action.s = 5, "action"
+ action2 = MockAddon()
+ action2.i, action2.s = 10, "action2"
+
+ #Check if the installed action is the good one
+ address = "Addr1"
+ #Set the return value of probe install
+ self.mockObj.MockRet["install"] = address
+ self.probeProxy.install(action, block=True)
+ assert pickle.loads(self.mockObj.MockCall["install"]["args"][0]) == action, "1 argument, the action"
+
+ #ErrorCase: Update should fail on noninstalled actions
+ self.assertRaises(RuntimeWarning, self.probeProxy.update, action2, action2, block=True)
+
+ #Test the update
+ self.probeProxy.update(action, action2, block=True)
+ args = self.mockObj.MockCall["update"]["args"]
+ assert args[0] == address, "arg 1 should be the action address"
+ assert pickle.loads(args[1]) == action2._props, "arg2 should be the new action properties"
+
+ #ErrorCase: Uninstall on not installed action (silent fail)
+ #Test the uninstall
+ self.probeProxy.uninstall(action2, block=True)
+ assert not "uninstall" in self.mockObj.MockCall, "Uninstall should not be called if action is not installed"
+
+ self.probeProxy.uninstall(action, block=True)
+ assert self.mockObj.MockCall["uninstall"]["args"][0] == address, "1 argument, the action address"
+
+ def test_events(self):
+ event = MockAddon()
+ event.i, event.s = 5, "event"
+ event2 = MockAddon()
+ event2.i, event2.s = 10, "event2"
+
+ def callback(event):
+ global message_box
+ message_box = event
+
+ #Check if the installed event is the good one
+ address = "Addr1"
+ #Set the return value of probe subscribe
+ self.mockObj.MockRet["subscribe"] = address
+ self.probeProxy.subscribe(event, callback, block=True)
+ assert pickle.loads(self.mockObj.MockCall["subscribe"]["args"][0]) == event, "1 argument, the event"
+
+ #Call the callback with the event
+ global message_box
+ self.mockObj.MockCB["eventOccured"]["handler_function"](pickle.dumps(event))
+ assert message_box == event, "callback should have been called with event"
+ message_box = None
+
+ #ErrorCase: eventOccured triggered by a wrong event
+ #Call with a wrong event
+ self.mockObj.MockCB["eventOccured"]["handler_function"](pickle.dumps(event2))
+ assert message_box is None, "callback should not have been called"
+
+
+ #ErrorCase: unsubcribe for non subscribed event
+ #Test the unsubscribe
+ self.probeProxy.unsubscribe("otheraddress", block=True)
+ assert not "unsubscribe" in self.mockObj.MockCall, "Unsubscribe should not be called if event is not subscribeed"
+
+ self.probeProxy.unsubscribe(address, block=True)
+ assert self.mockObj.MockCall["unsubscribe"]["args"][0] == address, "1 argument, the event address"
+
+ #ErrorCase: eventOccured triggered by uninstalled event
+ #Test the callback with unregistered event
+ self.mockObj.MockCB["eventOccured"]["handler_function"](pickle.dumps(event))
+ assert message_box is None, "callback should not have been called"
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/propertiestests.py b/tests/propertiestests.py
index 46346c4..f07bd43 100644
--- a/tests/propertiestests.py
+++ b/tests/propertiestests.py
@@ -15,6 +15,9 @@
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
import unittest
+import uuid
+import os
+import copy
from sugar.tutorius.constraints import *
from sugar.tutorius.properties import *
@@ -81,7 +84,128 @@ class BasePropertyTest(unittest.TestCase):
obj.prop = 2
assert obj.prop == 2, "Unable to set a value on base class"
+
+ def test_eq_(self):
+ class klass(TPropContainer):
+ prop = TutoriusProperty()
+ obj = klass()
+
+ obj2 = klass()
+
+ assert obj == obj2, "Base property containers should be identical"
+class AdvancedPropertyTest(unittest.TestCase):
+ def test_properties_groups(self):
+ """
+ Tests complex properties containers for identity.
+ """
+
+ class klass1(TPropContainer):
+ message = TutoriusProperty()
+ property = TutoriusProperty()
+ data = TutoriusProperty()
+
+ class klass3(TPropContainer):
+ property = TutoriusProperty()
+ message = TutoriusProperty()
+ data = TutoriusProperty()
+ extra_prop = TutoriusProperty()
+
+ class klass4(TPropContainer):
+ property = TutoriusProperty()
+ message = TutoriusProperty()
+ data = TFloatProperty(13.0)
+
+ obj1 = klass1()
+ obj1.property = 12
+ obj1.message = "Initial message"
+ obj1.data = [132, 208, 193, 142]
+
+ obj2 = klass1()
+ obj2.property = 12
+ obj2.message = "Initial message"
+ obj2.data = [132, 208, 193, 142]
+
+ obj3 = klass3()
+ obj3.property = 12
+ obj3.message = "Initial message"
+ obj3.data = [132, 208, 193, 142]
+ obj3.extra_prop = "Suprprise!"
+
+ obj4 = klass4()
+ obj4.property = 12
+ obj4.message = "Initial message"
+ obj4.data = 13.4
+
+ # Ensure that both obj1 and obj2 are identical (they have the same list of
+ # properties and they have the same values
+ assert obj1 == obj1, "Identical objects were considered as different"
+
+ # Ensure that obj1 is different from obj3, since obj3 has an extra property
+ assert not (obj1 == obj3), "Objects should not be identical since obj3 has more props"
+ assert not (obj3 == obj1), "Objects should not be identical since obj3 has more properties"
+
+ # Ensure that properties of different type are considered as different
+ assert not (obj1 == obj4), "Properties of different type should not be equal"
+
+ def test_addon_properties(self):
+ """Test an addon property.
+
+ This tests creates a class with a single addon property (klass1) and
+ assigns a new addon to it (inner1)."""
+ class klass1(TPropContainer):
+ addon = TAddonProperty()
+
+ class inner1(TPropContainer):
+ internal = TutoriusProperty()
+ def __init__(self, value):
+ TPropContainer.__init__(self)
+ self.internal = value
+
+ obj1 = klass1()
+ obj1.addon = inner1("Hi!")
+
+ obj2 = klass1()
+ obj2.addon = inner1("Hi!")
+
+ assert obj1 == obj2, "Identical objects with addon properties were treated as different"
+
+ obj3 = klass1()
+ obj3.addon = inner1("Hello!")
+
+ assert not (obj1 == obj3), "Objects with addon property having a different value should be considered different"
+
+ def test_addonlist_properties(self):
+ class klass1(TPropContainer):
+ addon_list = TAddonListProperty()
+
+ class inner1(TPropContainer):
+ message = TutoriusProperty()
+ data = TutoriusProperty()
+ def __init__(self, message, data):
+ TPropContainer.__init__(self)
+ self.message = message
+ self.data = data
+
+ class inner2(TPropContainer):
+ message = TutoriusProperty()
+ other_data = TutoriusProperty()
+ def __init__(self, message, data):
+ TPropContainer.__init__(self)
+ self.message = message
+ self.other_data = data
+
+ obj1 = klass1()
+ obj1.addon_list = [inner1('Hi!', 12), inner1('Hello.', [1,2])]
+ obj2 = klass1()
+ obj2.addon_list = [inner1('Hi!', 12), inner1('Hello.', [1,2])]
+
+ assert obj1 == obj2, "Addon lists with the same containers were considered different"
+
+ obj3 = klass1()
+ obj3.addon_list = [inner1('Hi!', 12), inner2('Hello.', [1,2])]
+ assert not (obj1 == obj3), "Differently named properties should be considered different in the addon list tests"
+
class TIntPropertyTest(unittest.TestCase):
def test_int_property(self):
class klass(TPropContainer):
@@ -251,7 +375,7 @@ class TArrayPropertyTest(unittest.TestCase):
prop = TArrayProperty([1, 2, 3, 4])
obj = klass()
- assert obj.prop == [1,2,3,4], "Unable to set initial value via constructor"
+ assert obj.prop == (1,2,3,4), "Unable to set initial value via constructor"
assert klass.prop.type == "array", "Wrong type for array : %s"%klass.prop.type
@@ -377,19 +501,38 @@ class TEnumPropertyTest(unittest.TestCase):
try_wrong_values(self.obj)
class TFilePropertyTest(unittest.TestCase):
+ root_folder = "/tmp/tutorius"
+
def setUp(self):
+ try:
+ os.mkdir(self.root_folder)
+ except:
+ pass
+ # Create some sample, unique files for the tests
+ self.temp_filename1 = os.path.join(self.root_folder, "sample_file1_" + str(uuid.uuid1()) + ".txt")
+ self.temp_file1 = file(self.temp_filename1, "w")
+ self.temp_file1.close()
+ self.temp_filename2 = os.path.join(self.root_folder, "sample_file2_" + str(uuid.uuid1()) + ".txt")
+ self.temp_file2 = file(self.temp_filename2, "w")
+ self.temp_file2.close()
+
class klass(TPropContainer):
- prop = TFileProperty("propertiestests.py")
+ prop = TFileProperty(self.temp_filename1)
self.obj = klass()
+
+ def tearDown(self):
+ # Unlink the files from the disk when tests are over
+ os.unlink(self.temp_filename1)
+ os.unlink(self.temp_filename2)
def test_basic_file(self):
- assert self.obj.prop == "propertiestests.py", "Could not set initial value"
+ assert self.obj.prop == self.temp_filename1, "Could not set initial value"
assert type(self.obj).prop.type == "file", "Wrong type for TFileProperty : %s"%type(self.obj).prop.type
- self.obj.prop = "run-tests.py"
+ self.obj.prop = self.temp_filename2
- assert self.obj.prop == "run-tests.py", "Could not change value"
+ assert self.obj.prop == self.temp_filename2, "Could not change value"
try:
self.obj.prop = "unknown/file/on/disk.gif"
@@ -397,6 +540,102 @@ class TFilePropertyTest(unittest.TestCase):
except FileConstraintError:
pass
+class TAddonPropertyTest(unittest.TestCase):
+ def test_wrong_value(self):
+ class klass1(TPropContainer):
+ addon = TAddonProperty()
+
+ class wrongAddon(object):
+ pass
+
+ obj1 = klass1()
+ obj1.addon = klass1()
+
+ try:
+ obj1.addon = wrongAddon()
+ assert False, "Addon Property should not accept non-TPropContainer values"
+ except ValueError:
+ pass
+
+class TAddonPropertyList(unittest.TestCase):
+ def test_wrong_value(self):
+ class klass1(TPropContainer):
+ addonlist = TAddonListProperty()
+
+ class wrongAddon(object):
+ pass
+
+ obj1 = klass1()
+
+ obj1.addonlist = [klass1(), klass1()]
+
+ try:
+ obj1.addonlist = klass1()
+ assert False, "TAddonPropeprty shouldn't accept anything else than a list"
+ except ValueError:
+ pass
+
+ try:
+ obj1.addonlist = [klass1(), klass1(), wrongAddon(), klass1()]
+ except ValueError:
+ pass
+
+class TResourcePropertyTest(unittest.TestCase):
+ def test_valid_names(self):
+ class klass1(TPropContainer):
+ res = TResourceProperty()
+
+ name1 = "file_" + unicode(uuid.uuid1()) + ".png"
+ name2 = unicode(uuid.uuid1()) + "_" + unicode(uuid.uuid1()) + ".extension"
+ name3 = "/home/user/.sugar/_random/new_image1231_" + unicode(uuid.uuid1()).upper() + ".mp3"
+ name4 = "a_" + unicode(uuid.uuid1())
+ name5 = ""
+
+ obj1 = klass1()
+
+ obj1.res = name1
+ assert obj1.res == name1, "Could not assign the valid name correctly : %s" % name1
+
+ obj1.res = name2
+ assert obj1.res == name2, "Could not assign the valid name correctly : %s" % name2
+
+ obj1.res = name3
+ assert obj1.res == name3, "Could not assign the valid name correctly : %s" % name3
+
+ obj1.res = name4
+ assert obj1.res == name4, "Could not assign the valid name correctly : %s" % name4
+
+ obj1.res = name5
+ assert obj1.res == name5, "Could not assign the valid name correctly : %s" % name5
+
+ def test_invalid_names(self):
+ class klass1(TPropContainer):
+ res = TResourceProperty()
+
+ bad_name1 = ".jpg"
+ bad_name2 = "_.jpg"
+ bad_name3 = "_" + unicode(uuid.uuid1())
+
+ obj1 = klass1()
+
+ try:
+ obj1.res = bad_name1
+ assert False, "A invalid name was accepted : %s" % bad_name1
+ except ResourceConstraintError:
+ pass
+
+ try:
+ obj1.res = bad_name2
+ assert False, "A invalid name was accepted : %s" % bad_name2
+ except ResourceConstraintError:
+ pass
+
+ try:
+ obj1.res = bad_name3
+ assert False, "A invalid name was accepted : %s" % bad_name3
+ except ResourceConstraintError:
+ pass
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/ressources/icon.svg b/tests/ressources/icon.svg
new file mode 100644
index 0000000..bb28f04
--- /dev/null
+++ b/tests/ressources/icon.svg
@@ -0,0 +1,21 @@
+<?xml version="1.0" ?><!-- Created with Inkscape (http://www.inkscape.org/) --><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd' [
+ <!ENTITY stroke_color "#000000">
+ <!ENTITY fill_color "#ffffff">
+]><svg height="55px" id="svg2393" inkscape:output_extension="org.inkscape.output.svg.inkscape" inkscape:version="0.47pre1 r21720" sodipodi:docname="tutortool.svg" sodipodi:version="0.32" version="1.1" width="55px" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg">
+ <defs id="defs2395">
+ <inkscape:perspective id="perspective2401" inkscape:persp3d-origin="16 : 10.666667 : 1" inkscape:vp_x="0 : 16 : 1" inkscape:vp_y="0 : 1000 : 0" inkscape:vp_z="32 : 16 : 1" sodipodi:type="inkscape:persp3d"/>
+ </defs>
+ <sodipodi:namedview bordercolor="#666666" borderopacity="1.0" id="base" inkscape:current-layer="layer1" inkscape:cx="3.7661233" inkscape:cy="33.132055" inkscape:document-units="px" inkscape:grid-bbox="true" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:window-height="675" inkscape:window-maximized="0" inkscape:window-width="1057" inkscape:window-x="108" inkscape:window-y="45" inkscape:zoom="3.9590209" pagecolor="#ffffff" showgrid="true"/>
+ <metadata id="metadata2398">
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g id="layer1" inkscape:groupmode="layer" inkscape:label="Layer 1" transform="translate(0,23)">
+ <path d="m 38.01548,1.5478747 c 0,7.1837999 -7.3667,13.0141283 -16.443525,13.0141283 -2.269208,0 -8.124729,3.152936 -13.9608513,4.252763 C 13.382177,14.110994 11.434521,11.926642 9.9463815,10.748864 6.9701032,8.3933076 5.1284282,5.1397735 5.1284282,1.5478747 c 0,-7.1837994 7.3666998,-13.0141297 16.4435268,-13.0141297 9.076825,0 16.443525,5.8303303 16.443525,13.0141297 z" id="path2403" sodipodi:nodetypes="cscsssc" style="fill:&fill_color;;fill-opacity:1;fill-rule:nonzero;stroke:&stroke_color;;stroke-width:1.96931934;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"/>
+ <path d="m 50.150276,6.4721386 c 0,2.621116 -1.428036,4.9953144 -3.735846,6.7142344 -1.153905,0.85946 -1.824287,2.434433 1.398853,6.784273 -6.258422,-3.991066 -8.65379,-4.001712 -10.413335,-4.001712 -7.03818,0 -12.750327,-4.254565 -12.750327,-9.4967954 0,-5.2422321 5.712147,-9.4967971 12.750327,-9.4967971 7.038182,0 12.750328,4.254565 12.750328,9.4967971 z" id="path3175" sodipodi:nodetypes="cccsssc" style="fill:&fill_color;;fill-opacity:1;fill-rule:nonzero;stroke:&stroke_color;;stroke-width:1.96931934;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"/>
+ </g>
+</svg> \ No newline at end of file
diff --git a/tests/run-tests.py b/tests/run-tests.py
deleted file mode 100755
index 23d7e24..0000000
--- a/tests/run-tests.py
+++ /dev/null
@@ -1,77 +0,0 @@
-#!/usr/bin/python
-# This is a dumb script to run tests on the sugar-jhbuild installed files
-# The path added is the default path for the jhbuild build
-
-
-import os, sys
-INSTALL_PATH=os.path.join(os.path.dirname(__file__),"../../sugar-jhbuild/install/lib/python2.6/site-packages/")
-sys.path.insert(0,
- os.path.abspath(INSTALL_PATH)
-)
-
-FULL_PATH = os.path.join(INSTALL_PATH,"sugar/tutorius")
-SUBDIRS = ["uam"]
-GLOB_PATH = os.path.join(FULL_PATH,"*.py")
-import unittest
-from glob import glob
-def report_files():
- ret = glob(GLOB_PATH)
- for dir in SUBDIRS:
- ret += glob(os.path.join(FULL_PATH,dir,"*.py"))
- return ret
-
-import sys
-if __name__=='__main__':
- if "--coverage" in sys.argv:
- sys.argv=[arg for arg in sys.argv if arg != "--coverage"]
- import coverage
- coverage.erase()
- #coverage.exclude('raise NotImplementedError')
- coverage.start()
-
- import coretests
- import servicestests
- import gtkutilstests
- #import overlaytests # broken
- import linear_creatortests
- import actiontests
- import uamtests
- import filterstests
- import constraintstests
- import propertiestests
- import serializertests
- import probetests
- suite = unittest.TestSuite()
- suite.addTests(unittest.findTestCases(coretests))
- suite.addTests(unittest.findTestCases(servicestests))
- suite.addTests(unittest.findTestCases(gtkutilstests))
- #suite.addTests(unittest.findTestCases(overlaytests)) # broken
- suite.addTests(unittest.findTestCases(linear_creatortests))
- suite.addTests(unittest.findTestCases(actiontests))
- suite.addTests(unittest.findTestCases(uamtests))
- suite.addTests(unittest.findTestCases(filterstests))
- suite.addTests(unittest.findTestCases(constraintstests))
- suite.addTests(unittest.findTestCases(propertiestests))
- suite.addTests(unittest.findTestCases(serializertests))
- suite.addTests(unittest.findTestCases(probetests))
- runner = unittest.TextTestRunner()
- runner.run(suite)
- coverage.stop()
- coverage.report(report_files())
- coverage.erase()
- else:
- from coretests import *
- from servicestests import *
- from gtkutilstests import *
- #from overlaytests import * # broken
- from actiontests import *
- from linear_creatortests import *
- from uamtests import *
- from filterstests import *
- from constraintstests import *
- from propertiestests import *
- from actiontests import *
- from serializertests import *
- from probetests import *
-
- unittest.main()
diff --git a/tests/serializertests.py b/tests/serializertests.py
deleted file mode 100644
index 6c25bae..0000000
--- a/tests/serializertests.py
+++ /dev/null
@@ -1,197 +0,0 @@
-# Copyright (C) 2009, Tutorius.org
-# Copyright (C) 2009, Jean-Christophe Savard <savard.jean.christophe@gmail.com>
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
-"""
-Serialization Tests
-
-This module contains all the tests that pertain to the usage of the Tutorius
-Serializer object. This means testing saving a tutorial dictionary to a .tml
-file, loading the list of tutorials for this activity and building chosen
-tutorial.
-"""
-
-import unittest
-
-import os
-import shutil
-
-from sugar.tutorius import bundler, addon
-from sugar.tutorius.core import State, FiniteStateMachine
-from sugar.tutorius.actions import *
-from sugar.tutorius.filters import *
-from sugar.tutorius.bundler import XMLSerializer, Serializer
-import sugar
-from uuid import uuid1
-
-class SerializerInterfaceTest(unittest.TestCase):
- """
- For completeness' sake.
- """
- def test_save(self):
- ser = Serializer()
-
- try:
- ser.save_fsm(None)
- assert False, "save_fsm() should throw an unimplemented error"
- except:
- pass
-
- def test_load(self):
- ser = Serializer()
-
- try:
- ser.load_fsm(str(uuid.uuid1()))
- assert False, "load_fsm() should throw an unimplemented error"
- except:
- pass
-
-class XMLSerializerTest(unittest.TestCase):
- """
- Tests the transformation of XML to FSM, then back.
- """
- def setUp(self):
- # Make the serializer believe the test is in a activity path
- self.testpath = "/tmp/testdata/"
- os.environ["SUGAR_BUNDLE_PATH"] = self.testpath
- os.environ["SUGAR_PREFIX"] = self.testpath
- os.environ["SUGAR_PROFILE"] = 'test'
-## os.mkdir(sugar.tutorius.bundler._get_store_root())
-
- # Create the sample FSM
- self.fsm = FiniteStateMachine("testingMachine")
-
- # Add a few states
- act1 = addon.create('BubbleMessage', message="Hi", pos=[300, 450])
- ev1 = addon.create('GtkWidgetEventFilter', "0.12.31.2.2", "clicked", "Second")
- act2 = addon.create('BubbleMessage', message="Second message", pos=[250, 150], tailpos=[1,2])
-
- st1 = State("INIT")
- st1.add_action(act1)
- st1.add_event_filter(ev1)
-
- st2 = State("Second")
-
- st2.add_action(act2)
-
- self.fsm.add_state(st1)
- self.fsm.add_state(st2)
-
- self.uuid = uuid1()
-
- # Flag to set to True if the output can be deleted after execution of
- # the test
- self.remove = True
-
- def tearDown(self):
- """
- Removes the created files, if need be.
- """
- if self.remove == True:
- shutil.rmtree(os.path.join(os.getenv("HOME"),".sugar",os.getenv("SUGAR_PROFILE")))
- if os.path.isdir(self.testpath):
- shutil.rmtree(self.testpath)
-
- def test_save(self):
- """
- Writes an FSM to disk, then compares the file to the expected results.
- "Remove" boolean argument specify if the test data must be removed or not
- """
- xml_ser = XMLSerializer()
- os.makedirs(os.path.join(sugar.tutorius.bundler._get_store_root(), str(self.uuid)))
- #rpdb2.start_embedded_debugger('flakyPass')
- xml_ser.save_fsm(self.fsm, bundler.TUTORIAL_FILENAME, os.path.join(sugar.tutorius.bundler._get_store_root(), str(self.uuid)))
-
- def test_save_and_load(self):
- """
- Load up the written FSM and compare it with the object representation.
- """
- self.test_save()
- testpath = "/tmp/testdata/"
- #rpdb2.start_embedded_debugger('flakyPass')
- xml_ser = XMLSerializer()
-
- # This interface needs to be redone... It's not clean because there is
- # a responsibility mixup between the XML reader and the bundler.
- loaded_fsm = xml_ser.load_fsm(str(self.uuid))
-
- # Compare the two FSMs
- assert loaded_fsm._states.get("INIT").name == self.fsm._states.get("INIT").name, \
- 'FSM underlying dictionary differ from original to pickled/reformed one'
- assert loaded_fsm._states.get("Second").name == self.fsm._states.get("Second").name, \
- 'FSM underlying dictionary differ from original to pickled/reformed one'
- assert loaded_fsm._states.get("INIT").get_action_list()[0].message == \
- self.fsm._states.get("INIT").get_action_list()[0].message, \
- 'FSM underlying State underlying Action differ from original to reformed one'
- assert len(loaded_fsm.get_action_list()) == 0, "FSM should not have any actions on itself"
-
- def test_all_actions(self):
- """
- Inserts all the known action types in a FSM, then attempt to load it.
- """
- st = State("INIT")
-
- act1 = addon.create('BubbleMessage', "Hi!", pos=[10,120], tailpos=[-12,30])
- act2 = addon.create('DialogMessage', "Hello again.", pos=[120,10])
- act3 = WidgetIdentifyAction()
- act4 = DisableWidgetAction("0.0.0.1.0.0.0")
- act5 = TypeTextAction("0.0.0.1.1.1.0.0", "New text")
- act6 = ClickAction("0.0.1.0.1.1")
- act7 = OnceWrapper(act1)
- act8 = ChainAction([act1, act2, act3, act4])
- actions = [act1, act2, act3, act4, act5, act6, act7, act8]
-
- for action in actions:
- st.add_action(action)
-
- self.fsm.remove_state("Second")
- self.fsm.remove_state("INIT")
- self.fsm.add_state(st)
-
- xml_ser = XMLSerializer()
-
- self.test_save()
-
- reloaded_fsm = xml_ser.load_fsm(str(self.uuid))
- assert self.fsm == reloaded_fsm, "Expected equivalence before saving vs after loading."
-
- def test_all_filters(self):
- """
- Inserts all the known action types in a FSM, then attempt to load it.
- """
- st = State("INIT")
-
- ev1 = TimerEvent("Second", 1000)
- ev2 = addon.create('GtkWidgetEventFilter', "Second", "0.0.1.1.0.0.1", "clicked")
- ev3 = GtkWidgetTypeFilter("Second", "0.0.1.1.1.2.3", text="Typed stuff")
- ev4 = GtkWidgetTypeFilter("Second", "0.0.1.1.1.2.3", strokes="acbd")
- filters = [ev1, ev2, ev3, ev4]
-
- for filter in filters:
- st.add_event_filter(filter)
-
- self.fsm.remove_state("INIT")
- self.fsm.add_state(st)
-
- xml_ser = XMLSerializer()
-
- self.test_save()
-
- reloaded_fsm = xml_ser.load_fsm(str(self.uuid))
-
- assert self.fsm == reloaded_fsm, "Expected equivalence before saving vs after loading."
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/skip b/tests/skip
index 882d14d..3868383 100644
--- a/tests/skip
+++ b/tests/skip
@@ -1,3 +1,4 @@
+utils.py
run-tests.py
overlaytests.py
viewer.py
diff --git a/tests/storetests.py b/tests/storetests.py
new file mode 100644
index 0000000..1752fe6
--- /dev/null
+++ b/tests/storetests.py
@@ -0,0 +1,140 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import unittest
+from tests.utils import skip, catch_unimplemented
+
+import random
+from sugar.tutorius.store import *
+
+g_tutorial_id = '4079'
+g_other_id = '4080'
+
+class StoreProxyTest(unittest.TestCase):
+ def setUp(self):
+ self.store = StoreProxy("http://bobthebuilder.mine.nu/tutorius/en-US/tutorius")
+
+ def tearDown(self):
+ pass
+
+ @catch_unimplemented
+ def test_get_categories(self):
+ categories = self.store.get_categories()
+
+ assert isinstance(categories, list), "categories should be a list"
+
+ @catch_unimplemented
+ def test_get_tutorials(self):
+ self.store.get_tutorials()
+
+ def test_get_latest_version(self):
+ version_dict = self.store.get_latest_version([])
+
+ assert isinstance(version_dict, dict)
+
+ @catch_unimplemented
+ def test_download_tutorial(self):
+ tutorial = self.store.download_tutorial(g_other_id)
+
+ assert tutorial is not None
+
+ @catch_unimplemented
+ def test_login(self):
+ assert self.store.login("benoit.tremblay1@gmail.com", "tutorius12")
+
+ @catch_unimplemented
+ def test_register_new_user(self):
+ random_num = str(random.randint(0, 999999999))
+ user_info = {
+ 'nickname' : "Albert%s" % (random_num),
+ 'password' : "tutorius12",
+ 'email' : 'albertthetester%s@mozambique.org' % (random_num)
+ }
+
+ assert self.store.register_new_user(user_info)
+
+
+class StoreProxyLoginTest(unittest.TestCase):
+ @catch_unimplemented
+ def setUp(self):
+ self.store = StoreProxy("http://bobthebuilder.mine.nu/tutorius/en-US/tutorius")
+ self.store.login("nobody@mozilla.org", "tutorius12")
+
+ @catch_unimplemented
+ def tearDown(self):
+ session_id = self.store.get_session_id()
+
+ if session_id is not None:
+ self.store.close_session()
+
+
+ @catch_unimplemented
+ def test_get_session_id(self):
+ session_id = self.store.get_session_id()
+
+ assert session_id is not None
+
+ @catch_unimplemented
+ def test_rate(self):
+ assert self.store.rate(5, g_tutorial_id)
+
+ @catch_unimplemented
+ def test_publish(self):
+ # TODO : We need to send in a real tutorial loaded from
+ # the Vault
+ tutorial_info = {
+ 'name': 'newtut',
+ 'summary': 'This is a tutorial',
+ 'filename': 'test.xml',
+ 'homepage': 'http://google.com',
+ 'version': '1',
+ 'cat1': '17',
+ 'cat2': '18',
+ 'cat3': ''
+ }
+ assert self.store.publish('This should be a real tutorial...', tutorial_info)
+
+ @catch_unimplemented
+ def test_unpublish(self):
+ assert self.store.unpublish(g_tutorial_id)
+
+ # Republish the tutorial
+ self.store.publish(None, None, g_tutorial_id)
+
+ def test_republish(self):
+ assert self.store.publish(None, None, g_tutorial_id)
+
+ @catch_unimplemented
+ def test_update_published_tutorial(self):
+ # TODO : Run these tests with files from the Vault
+ #self.store.publish([g_tutorial_id, 'Fake tutorial'])
+
+ tutorial_info = {
+ 'name': 'newtut',
+ 'summary': 'This is an updated tutorial',
+ 'filename': 'test.xml',
+ 'homepage': 'http://google.com',
+ 'version': '2',
+ 'cat1': '17',
+ 'cat2': '18',
+ 'cat3': ''
+ }
+
+ assert self.store.update_published_tutorial(g_tutorial_id, 'This is an updated tutorial', tutorial_info)
+
+ def test_close_session(self):
+ assert self.store.close_session()
+
diff --git a/tests/translatortests.py b/tests/translatortests.py
new file mode 100644
index 0000000..3b5ca6f
--- /dev/null
+++ b/tests/translatortests.py
@@ -0,0 +1,131 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import unittest
+import os
+import uuid
+
+from sugar.tutorius.translator import *
+from sugar.tutorius.properties import *
+from sugar.tutorius.tutorial import *
+from sugar.tutorius.vault import Vault
+from sugar.tutorius import addon
+
+##############################################################################
+## Helper classes
+class ResourceAction(TPropContainer):
+ resource = TResourceProperty()
+
+ def __init__(self):
+ TPropContainer.__init__(self)
+
+class NestedResource(TPropContainer):
+ nested = TAddonProperty()
+
+ def __init__(self):
+ TPropContainer.__init__(self)
+ self.nested = ResourceAction()
+
+class ListResources(TPropContainer):
+ nested_list = TAddonListProperty()
+
+ def __init__(self):
+ TPropContainer.__init__(self)
+ self.nested_list = [ResourceAction(), ResourceAction()]
+
+##
+##############################################################################
+
+class ResourceTranslatorTests(unittest.TestCase):
+ temp_path = "/tmp/"
+ file_name = "file.txt"
+
+ def setUp(self):
+ # Generate a tutorial ID
+ self.tutorial_id = unicode(uuid.uuid1())
+
+ # Create a dummy fsm
+ self.fsm = Tutorial("TestTutorial1")
+ # Add a few states
+ act1 = addon.create('BubbleMessage', message="Hi", position=[300, 450])
+ ev1 = addon.create('GtkWidgetEventFilter', "0.12.31.2.2", "clicked")
+ act2 = addon.create('BubbleMessage', message="Second message", position=[250, 150], tail_pos=[1,2])
+ self.fsm.add_action("INIT", act1)
+ st2 = self.fsm.add_state((act2,))
+ self.fsm.add_transition("INIT",(ev1, st2))
+
+ # Create a dummy metadata dictionnary
+ self.test_metadata_dict = {}
+ self.test_metadata_dict['name'] = 'TestTutorial1'
+ self.test_metadata_dict['guid'] = unicode(self.tutorial_id)
+ self.test_metadata_dict['version'] = '1'
+ self.test_metadata_dict['description'] = 'This is a test tutorial 1'
+ self.test_metadata_dict['rating'] = '3.5'
+ self.test_metadata_dict['category'] = 'Test'
+ self.test_metadata_dict['publish_state'] = 'false'
+ activities_dict = {}
+ activities_dict['org.laptop.tutoriusactivity'] = '1'
+ activities_dict['org.laptop,writus'] = '1'
+ self.test_metadata_dict['activities'] = activities_dict
+
+ Vault.saveTutorial(self.fsm, self.test_metadata_dict)
+
+ try:
+ os.mkdir(self.temp_path)
+ except:
+ pass
+ abs_file_path = os.path.join(self.temp_path, self.file_name)
+ new_file = file(abs_file_path, "w")
+
+ # Add the resource in the Vault
+ self.res_name = Vault.add_resource(self.tutorial_id, abs_file_path)
+
+ # Use a dummy prob manager - we shouldn't be using it
+ self.prob_man = object()
+
+ self.translator = ResourceTranslator(self.prob_man, self.tutorial_id)
+
+ def tearDown(self):
+ Vault.deleteTutorial(self.tutorial_id)
+
+ os.unlink(os.path.join(self.temp_path, self.file_name))
+
+ def test_translate(self):
+ # Create an action with a resource property
+ res_action = ResourceAction()
+ res_action.resource = self.res_name
+
+ self.translator.translate(res_action)
+
+ assert getattr(res_action, "resource").type == "file", "Resource was not converted to file"
+
+ assert res_action.resource.default == Vault.get_resource_path(self.tutorial_id, self.res_name), "Transformed resource path is not the same as the one given by the vault"
+
+ def test_recursive_translate(self):
+ nested_action = NestedResource()
+
+ self.translator.translate(nested_action)
+
+ assert getattr(getattr(nested_action, "nested"), "resource").type == "file", "Nested resource was not converted properly"
+
+ def test_list_translate(self):
+ list_action = ListResources()
+
+ self.translator.translate(list_action)
+
+ for container in list_action.nested_list:
+ assert getattr(container, "resource").type == "file", "Element of list was not converted properly"
+
diff --git a/tests/tutorialtests.py b/tests/tutorialtests.py
new file mode 100644
index 0000000..23d5fc8
--- /dev/null
+++ b/tests/tutorialtests.py
@@ -0,0 +1,416 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Erick Lavoie <erick.lavoie@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+Tutorial Tests
+"""
+
+# TODO: Add tests for 47, 52, 55, 109, 132, 175, 209, 229, 233, 271, 274, 292, 295, 318, 337, 375, 394, 411, 428, 446, 480, 491, 624, 637, 698
+import unittest
+
+from sugar.tutorius.tutorial import *
+
+# The following tests are organized around 4 classes:
+#
+# Black box tests:
+# Those tests should limit themselves to exercise the
+# interface of the object so everything should be tested
+# only through the interface the object offers. This will
+# ease test maintenance since we anticipate most changes
+# will be about the implementation of an object and not
+# its interface.
+#
+# Tests definitions are written assuming the previous tests
+# did complete correctly so the number of things to assert
+# is minimal.
+#
+# Basic interface cases:
+# Test the interface of the object for trivial cases
+# just to assert that the functionality this object
+# offers really works
+#
+# Limit cases:
+# Test edge cases that cover more obscure usage
+# scenarios but that should be valid nonetheless
+#
+# Error cases:
+# Test wrong inputs to make sure that the object is hard
+# to misuse and do generate proper errors
+#
+# White box tests:
+# Those should be used only for really important algorithms
+# to make sure they behave correctly in every cases, otherwise
+# the tests will break each time we change something in the
+# implementation
+
+from sugar.tutorius.properties import *
+
+class MockAction(TPropContainer):
+ i = TIntProperty(0, 0, 9)
+
+class MockEvent(TPropContainer):
+ i = TIntProperty(0, 0, 9)
+
+class StateTest(unittest.TestCase):
+ """Test basic functionalities of states used by tutorials"""
+
+ def setUp(self):
+ self.state = State("State1")
+ self.action = MockAction()
+ self.event = MockEvent()
+
+ def tearDown(self):
+ pass
+
+ ######################### Basic interface cases #########################
+
+ #### Action
+ def test_add_dummy_action(self):
+ action_name = self.state.add_action("action1")
+ assert action_name
+ assert len(self.state.get_action_dict()) == 1
+ assert self.state.get_action_dict().has_key(action_name)
+ assert self.state.get_action_dict()[action_name] == "action1"
+
+ def test_add_generate_unique_action_names(self):
+ action_name1 = self.state.add_action("action1")
+ action_name2 = self.state.add_action("action2")
+ assert action_name1 and action_name2
+ assert action_name1 != action_name2
+
+ def test_update_dummy_action(self):
+ action_name = self.state.add_action(self.action)
+ assert self.action.i == 0
+
+ prop = self.action.get_properties_dict_copy()
+ prop["i"] = 2
+ self.state.update_action(action_name, prop)
+
+ assert len(self.state.get_action_dict()) == 1
+ assert self.state.get_action_dict().has_key(action_name)
+ assert self.state.get_action_dict()[action_name].get_properties_dict_copy() == prop
+
+ def test_delete_dummy_action(self):
+ action_name = self.state.add_action("action1")
+ assert len(self.state.get_action_dict()) == 1
+ assert self.state.get_action_dict().has_key(action_name)
+ assert self.state.get_action_dict()[action_name] == "action1"
+
+ self.state.delete_action(action_name)
+ assert len(self.state.get_action_dict()) == 0
+
+ def test_delete_all_dummy_actions(self):
+ action_name = self.state.add_action("action1")
+ assert len(self.state.get_action_dict()) == 1
+ assert self.state.get_action_dict().has_key(action_name)
+ assert self.state.get_action_dict()[action_name] == "action1"
+
+ self.state.delete_actions()
+ assert len(self.state.get_action_dict()) == 0
+
+ #### Transition
+ def test_add_dummy_transition(self):
+ transition_name = self.state.add_transition("transition1")
+ assert len(self.state.get_transition_dict()) == 1
+ assert self.state.get_transition_dict().has_key(transition_name)
+ assert self.state.get_transition_dict()[transition_name] == "transition1"
+
+ def test_add_generate_unique_transition_names(self):
+ transition_name1 = self.state.add_transition("transition1")
+ transition_name2 = self.state.add_transition("transition2")
+ assert transition_name1 != transition_name2
+
+ def test_update_dummy_transition(self):
+ transition_name = self.state.add_transition((self.event, Tutorial.END))
+ assert self.event.i == 0
+
+ prop = self.event.get_properties_dict_copy()
+ prop["i"] = 2
+ self.state.update_transition(transition_name, prop)
+
+ assert len(self.state.get_transition_dict()) == 1
+ assert self.state.get_transition_dict().has_key(transition_name)
+ assert self.state.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop
+ assert self.state.get_transition_dict()[transition_name][1] == Tutorial.END
+
+ # Now update only the transition
+ self.state.update_transition(transition_name, new_state=Tutorial.INIT)
+ assert self.state.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop
+ assert self.state.get_transition_dict()[transition_name][1] == Tutorial.INIT
+
+ def test_delete_dummy_transition(self):
+ transition_name = self.state.add_transition("transition1")
+ assert len(self.state.get_transition_dict()) == 1
+ assert self.state.get_transition_dict().has_key(transition_name)
+ assert self.state.get_transition_dict()[transition_name] == "transition1"
+
+ self.state.delete_transition(transition_name)
+ assert len(self.state.get_transition_dict()) == 0
+
+ def test_delete_all_dummy_transitions(self):
+ transition_name = self.state.add_transition("transition1")
+ assert len(self.state.get_transition_dict()) == 1
+ assert self.state.get_transition_dict().has_key(transition_name)
+ assert self.state.get_transition_dict()[transition_name] == "transition1"
+
+ self.state.delete_transitions()
+ assert len(self.state.get_transition_dict()) == 0
+
+
+
+ ######################### Limit cases ###################################
+ #### Action
+
+ #### Transition
+
+ ######################### Error cases ###################################
+ #### Action
+ def test_update_unknown_action(self):
+ try:
+ self.state.update_action("unknown_name", "action")
+ assert False
+ except LookupError:
+ pass
+
+ def test_delete_unknown_action(self):
+ try:
+ self.state.delete_action("unknown_name")
+ assert False
+ except LookupError:
+ pass
+
+ #### Transition
+ def test_add_existing_transition(self):
+ self.state.add_transition("transition")
+ try:
+ self.state.add_transition("transition")
+ assert False
+ except TransitionAlreadyExists:
+ pass
+
+class TutorialTest(unittest.TestCase):
+ """Test tutorial functionality"""
+
+ def setUp(self):
+ self.tutorial = Tutorial("Tutorial Test")
+ self.action = MockAction()
+ self.event = MockEvent()
+
+ def tearDown(self):
+ pass
+
+ ######################### Basic interface cases #########################
+
+ #### Tutorial
+ def test_default_initial_value_in_tutorial(self):
+ assert len(self.tutorial.get_state_dict()) == 2
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 1
+ assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {}
+ assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END]
+ assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT]
+ assert self.tutorial.get_following_states_dict(Tutorial.END) == {}
+
+ #### State
+ def test_add_default_state(self):
+ state_name = self.tutorial.add_state()
+ assert state_name
+ assert len(self.tutorial.get_state_dict()) == 3
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 1
+
+ def test_add_state_with_action(self):
+ state_name = self.tutorial.add_state(action_list=["action1"])
+ assert state_name
+ assert len(self.tutorial.get_state_dict()) == 3
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name])
+ assert len(self.tutorial.get_action_dict()) == 1
+ assert len(self.tutorial.get_transition_dict()) == 1
+
+ def test_add_state_with_transition(self):
+ state_name = self.tutorial.add_state(transition_list=[("event1",Tutorial.END)])
+ assert state_name
+ assert len(self.tutorial.get_state_dict()) == 3
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 2
+
+ def test_add_generate_unique_state_names(self):
+ state_name1 = self.tutorial.add_state()
+ state_name2 = self.tutorial.add_state()
+ assert state_name1 and state_name2
+ assert state_name1 != state_name2
+
+ def test_delete_lone_state(self):
+ state_name1 = self.tutorial.add_state()
+ self.tutorial.delete_state(state_name1)
+ assert len(self.tutorial.get_state_dict()) == 2
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 1
+ assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {}
+ assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END]
+ assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT]
+ assert self.tutorial.get_following_states_dict(Tutorial.END) == {}
+
+ def test_delete_linked_state(self):
+ state_name1 = self.tutorial.add_state()
+ self.tutorial.update_transition(Tutorial.INITIAL_TRANSITION_NAME, \
+ None, state_name1)
+ transition_name1 = self.tutorial.add_transition(state_name1,("event1", Tutorial.END))
+ self.tutorial.delete_state(state_name1)
+ assert len(self.tutorial.get_state_dict()) == 2
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 1
+ assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {}
+ assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END]
+ assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT]
+ assert self.tutorial.get_following_states_dict(Tutorial.END) == {}
+
+ #### Action
+ def test_add_dummy_action(self):
+ state_name = self.tutorial.add_state()
+ action_name = self.tutorial.add_action(state_name,"action1")
+ assert action_name
+ assert len(self.tutorial.get_action_dict()) == 1
+ assert self.tutorial.get_action_dict().has_key(action_name)
+ assert self.tutorial.get_action_dict()[action_name] == "action1"
+
+ def test_add_generate_unique_action_names(self):
+ state_name = self.tutorial.add_state()
+ action_name1 = self.tutorial.add_action(state_name,"action1")
+ action_name2 = self.tutorial.add_action(state_name,"action2")
+ assert action_name1 and action_name2
+ assert action_name1 != action_name2
+
+ def test_update_dummy_action(self):
+ state_name = self.tutorial.add_state()
+ action_name = self.tutorial.add_action(state_name,self.action)
+
+ prop = self.action.get_properties_dict_copy()
+ prop["i"] = 2
+ self.tutorial.update_action(action_name, prop)
+
+ assert len(self.tutorial.get_action_dict()) == 1
+ assert self.tutorial.get_action_dict().has_key(action_name)
+ assert self.tutorial.get_action_dict()[action_name].get_properties_dict_copy() == prop
+
+ def test_delete_dummy_action(self):
+ state_name = self.tutorial.add_state()
+ action_name = self.tutorial.add_action(state_name,"action1")
+ assert len(self.tutorial.get_action_dict()) == 1
+ assert self.tutorial.get_action_dict().has_key(action_name)
+ assert self.tutorial.get_action_dict()[action_name] == "action1"
+
+ self.tutorial.delete_action(action_name)
+ assert len(self.tutorial.get_action_dict()) == 0
+
+ #### Transition
+ def test_add_dummy_transition(self):
+ state_name = self.tutorial.add_state()
+ transition_name = self.tutorial.add_transition(state_name,"transition1")
+ assert len(self.tutorial.get_transition_dict()) == 2
+ assert self.tutorial.get_transition_dict().has_key(transition_name)
+ assert self.tutorial.get_transition_dict()[transition_name] == "transition1"
+
+ def test_add_generate_unique_transition_names(self):
+ state_name = self.tutorial.add_state()
+ transition_name1 = self.tutorial.add_transition(state_name,"transition1")
+ transition_name2 = self.tutorial.add_transition(state_name,"transition2")
+ assert transition_name1 and transition_name2
+ assert transition_name1 != transition_name2
+
+ def test_update_dummy_transition(self):
+ state_name = self.tutorial.add_state()
+ transition_name = self.tutorial.add_transition(state_name,(self.event, Tutorial.END))
+
+ prop = self.event.get_properties_dict_copy()
+ prop["i"] = 2
+ self.tutorial.update_transition(transition_name, prop)
+
+ assert len(self.tutorial.get_transition_dict()) == 2
+ assert self.tutorial.get_transition_dict().has_key(transition_name)
+ assert self.tutorial.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop
+ assert self.tutorial.get_transition_dict()[transition_name][1] == Tutorial.END
+
+ # Now update only the transition
+ self.tutorial.update_transition(transition_name, new_state=Tutorial.INIT)
+ assert self.tutorial.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop
+ assert self.tutorial.get_transition_dict()[transition_name][1] == Tutorial.INIT
+
+ def test_delete_dummy_transition(self):
+ state_name = self.tutorial.add_state()
+ transition_name = self.tutorial.add_transition(state_name,"transition1")
+ assert len(self.tutorial.get_transition_dict()) == 2
+ assert self.tutorial.get_transition_dict().has_key(transition_name)
+ assert self.tutorial.get_transition_dict()[transition_name] == "transition1"
+
+ self.tutorial.delete_transition(transition_name)
+ assert len(self.tutorial.get_transition_dict()) == 1
+
+
+ ######################### Limit cases ###################################
+ #### Tutorial
+
+ #### State
+
+ #### Action
+
+ #### Transition
+
+ ######################### Error cases ###################################
+ #### Tutorial
+
+ #### State
+
+ #### Action
+ def test_update_unknown_action(self):
+ lookup_error = None
+ try:
+ self.tutorial.update_action("unknown_name", "action")
+ except LookupError, e:
+ lookup_error = e
+
+ assert lookup_error
+
+
+ def test_delete_unknown_action(self):
+ lookup_error = None
+ try:
+ self.tutorial.delete_action("unknown_name")
+ except LookupError, e:
+ lookup_error = e
+
+ assert lookup_error
+
+ #### Transition
+ def test_add_existing_transition(self):
+ self.tutorial.add_transition(Tutorial.INIT,("event","transition"))
+ transition_exists_error = None
+ try:
+ self.tutorial.add_transition(Tutorial.INIT,("event","transition"))
+ except TransitionAlreadyExists, e:
+ transition_exists_error = e
+
+ assert transition_exists_error
+
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..98738b8
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,49 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Simon Poirier <simpoir@gmail.com>
+#
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+Here are some utility functions for easy tests maintenance
+"""
+
+def catch_unimplemented(fnct):
+ """
+ Decorator for globbing not implemented errors.
+ """
+ def ret(self, *args, **kwargs):
+ try:
+ fnct(self, *args, **kwargs)
+ print "PREVIOUSLY UNIMPLEMENTED TEST PASSES. REMOVE THIS DECORATOR: %s (%s.%s)"%\
+ (fnct.__name__, type(self).__module__, type(self).__name__)
+ except NotImplementedError:
+ pass
+ return ret
+
+
+def skip(msg):
+ """
+ Decorator for skipping pyunit tests.
+
+ @type msg: str
+ @param msg: reason for skipping the test
+ """
+ def ret(fnct):
+ print "SKIPPED TEST: %s (%s): %s"%(fnct.__name__, fnct.__module__, msg)
+ return ret
+
+
+# vim:set ts=4 sts=4 sw=4 et:
+
diff --git a/tests/vaulttests.py b/tests/vaulttests.py
new file mode 100644
index 0000000..ca61225
--- /dev/null
+++ b/tests/vaulttests.py
@@ -0,0 +1,489 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Jean-Christophe Savard <savard.jean.christophe@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+Vault Tests
+
+This module contains all the tests that pertain to the usage of the Tutorius
+Vault object. The Vault manage all the interactions with the various Tutorius
+modules dans the local file system. This include saving a tutorial to a .xml
+file, generating the metadata file, finding existing tutorials in the file
+system and building chosen tutorials.
+"""
+
+import unittest
+
+import os
+import shutil
+import zipfile
+import cStringIO
+
+from sugar.tutorius import addon
+from sugar.tutorius.tutorial import Tutorial
+from sugar.tutorius.actions import *
+from sugar.tutorius.filters import *
+from sugar.tutorius.vault import Vault, XMLSerializer, Serializer, TutorialBundler
+
+import sugar
+
+from uuid import uuid1
+
+class VaultInterfaceTest(unittest.TestCase):
+ """
+ Test the high-level interfaces functions of the Vault
+ """
+
+ def create_test_metadata_file(self, ini_file_path, guid):
+ ini_file = open(ini_file_path, 'wt')
+ ini_file.write("[GENERAL_METADATA]\n")
+ ini_file.write('guid=' + str(guid) + '\n')
+ ini_file.write('name=TestTutorial1\n')
+ ini_file.write('version=1\n')
+ ini_file.write('description=This is a test tutorial 1\n')
+ ini_file.write('rating=3.5\n')
+ ini_file.write('category=Test\n')
+ ini_file.write('publish_state=false\n')
+ ini_file.write('[RELATED_ACTIVITIES]\n')
+ ini_file.write('org.laptop.TutoriusActivity = 1\n')
+ ini_file.write('org.laptop.Writus = 1\n')
+ ini_file.close()
+
+
+ def setUp(self):
+ os.environ["SUGAR_BUNDLE_PATH"] = os.path.join(sugar.tutorius.vault._get_store_root(), 'test_bundle_path')
+ path = os.path.join(sugar.tutorius.vault._get_store_root(), 'test_bundle_path', 'data', 'tutorius', 'data')
+ if os.path.isdir(path) != True:
+ os.makedirs(path)
+
+ # Generate a first test GUID
+ self.test_guid = uuid1()
+ self.guid_path = os.path.join(sugar.tutorius.vault._get_store_root(),str(self.test_guid))
+ os.mkdir(self.guid_path)
+
+ # Create a first dummy .ini file
+ self.ini_file_path = os.path.join(self.guid_path, "meta.ini")
+ self.create_test_metadata_file(self.ini_file_path, self.test_guid)
+
+ # Generate a second test GUID
+ self.test_guid2 = uuid1()
+ self.guid_path2 = os.path.join(sugar.tutorius.vault._get_store_root(),str(self.test_guid2))
+ os.mkdir(self.guid_path2)
+
+ # Create a second dummy .ini file
+ self.ini_file_path2 = os.path.join(self.guid_path2, "meta.ini")
+
+ ini_file2 = open(self.ini_file_path2, 'wt')
+ ini_file2.write("[GENERAL_METADATA]\n")
+ ini_file2.write('guid=' + str(self.test_guid2) + '\n')
+ ini_file2.write('name=TestTutorial2\n')
+ ini_file2.write('version=2\n')
+ ini_file2.write('description=This is a test tutorial 2\n')
+ ini_file2.write('rating=4\n')
+ ini_file2.write('category=Test2\n')
+ ini_file2.write('publish_state=false\n')
+ ini_file2.write('[RELATED_ACTIVITIES]\n')
+ ini_file2.write('org.laptop.TutoriusActivity = 2\n')
+ ini_file2.write('org.laptop.Writus = 1\n')
+ ini_file2.write('org.laptop.Testus = 1\n')
+ ini_file2.close()
+
+ # Create a dummy fsm
+ self.fsm = Tutorial("TestTutorial1")
+ # Add a few states
+ act1 = addon.create('BubbleMessage', message="Hi", position=[300, 450])
+ ev1 = addon.create('GtkWidgetEventFilter', "0.12.31.2.2", "clicked")
+ act2 = addon.create('BubbleMessage', message="Second message", position=[250, 150], tail_pos=[1,2])
+ self.fsm.add_action("INIT", act1)
+ st2 = self.fsm.add_state((act2,))
+ self.fsm.add_transition("INIT",(ev1, st2))
+ self.tuto_guid = uuid1()
+
+ # Create a dummy metadata dictionnary
+ self.test_metadata_dict = {}
+ self.save_test_guid = uuid1()
+ self.test_metadata_dict['name'] = 'TestTutorial1'
+ self.test_metadata_dict['guid'] = str(self.save_test_guid)
+ self.test_metadata_dict['version'] = '1'
+ self.test_metadata_dict['description'] = 'This is a test tutorial 1'
+ self.test_metadata_dict['rating'] = '3.5'
+ self.test_metadata_dict['category'] = 'Test'
+ self.test_metadata_dict['publish_state'] = 'false'
+ activities_dict = {}
+ activities_dict['org.laptop.tutoriusactivity'] = '1'
+ activities_dict['org.laptop,writus'] = '1'
+ self.test_metadata_dict['activities'] = activities_dict
+
+
+ def test_installTutorials(self):
+
+ # TODO : Test for erronous file too (not .xml, not .ini, not .zip, etc.)
+
+ # create dummy tutorial
+
+ # create a test folder in the file
+ # system outside the Vault
+ test_path = os.path.join(os.getenv("HOME"),".sugar", 'default', 'tutorius', 'tmp')
+ if os.path.isdir(test_path) == True:
+ shutil.rmtree(os.path.join(os.getenv("HOME"),".sugar", 'default', 'tutorius', 'tmp'))
+ os.makedirs(test_path)
+
+ # Creat a dummy tutorial .xml file
+ serializer = XMLSerializer()
+
+ with file(os.path.join(test_path, 'tutorial.xml'), 'w') as fsmfile:
+ serializer.save_tutorial(self.fsm, fsmfile)
+
+ # Create a dummy tutorial metadata file
+ self.create_test_metadata_file(os.path.join(test_path, 'meta.ini'), self.tuto_guid)
+
+ #Zip these tutorials files in the pkzip file format
+ archive_list = [os.path.join(test_path, 'meta.ini'), os.path.join(test_path, 'tutorial.xml')]
+
+ zfilename = "TestTutorial.zip"
+
+ zout = zipfile.ZipFile(os.path.join(test_path, zfilename), "w")
+ for fname in archive_list:
+ fname_splitted = fname.rsplit('/')
+ file_only_name = fname_splitted[fname_splitted.__len__() - 1]
+ zout.write(fname, file_only_name)
+ zout.close()
+
+ # test if the file is a valid pkzip file
+ assert zipfile.is_zipfile(os.path.join(test_path, zfilename)) == True, "The zipping of the tutorial files failed."
+
+ # test installTutorials function
+ vault = Vault()
+
+ install_return = vault.installTutorials(test_path, 'TestTutorial.zip', False)
+ assert install_return != 2, "Tutorial install has returned an error"
+
+ # check if the tutorial is now in the vault
+ try :
+ bundler = TutorialBundler(self.tuto_guid)
+ bundler.get_tutorial_path(self.tuto_guid)
+ except IOError:
+ print("Cannot find the specified tutorial's GUID in the vault")
+
+
+ def test_query(self):
+ """
+ Test the query function that return a list of tutorials (dictionnaries) that
+ correspond to the specified parameters.
+ """
+
+ # Note : Temporary only test query that return ALL tutorials in the vault.
+ # TODO : Test with varying parameters
+
+ vault = Vault()
+
+ tutorial_list = vault.query()
+
+ if tutorial_list.__len__() < 2:
+ assert False, 'Error, list doesnt have enough tutorial in it : ' + str(tutorial_list.__len__()) + ' element'
+
+ for tuto_dictionnary in tutorial_list:
+ if tuto_dictionnary['name'] == 'TestTutorial1':
+ related = tuto_dictionnary['activities']
+ assert tuto_dictionnary['version'] == '1'
+ assert tuto_dictionnary['description'] == 'This is a test tutorial 1'
+ assert tuto_dictionnary['rating'] == '3.5'
+ assert tuto_dictionnary['category'] == 'Test'
+ assert tuto_dictionnary['publish_state'] == 'false'
+ assert related.has_key('org.laptop.tutoriusactivity')
+ assert related.has_key('org.laptop.writus')
+
+ elif tuto_dictionnary['name'] == 'TestTutorial2':
+ related = tuto_dictionnary['activities']
+ assert tuto_dictionnary['version'] == '2'
+ assert tuto_dictionnary['description'] == 'This is a test tutorial 2'
+ assert tuto_dictionnary['rating'] == '4'
+ assert tuto_dictionnary['category'] == 'Test2'
+ assert tuto_dictionnary['publish_state'] == 'false'
+ assert related.has_key('org.laptop.tutoriusactivity')
+ assert related.has_key('org.laptop.writus')
+ assert related.has_key('org.laptop.testus')
+
+ else:
+ assert False, 'list is empty or name property is wrong'
+
+
+ def test_loadTutorial(self):
+ """
+ Test the opening of a tutorial from the vault by passing it is guid and
+ returning the Tutorial object representation. This test verify that the
+ initial underlying FSM and the new loaded one are equivalent.
+ """
+
+ # call test_installTutorials to be sure that the tuto is now in the Vault
+ self.test_installTutorials()
+ bundler = TutorialBundler(self.tuto_guid)
+ test = bundler.get_tutorial_path(self.tuto_guid)
+ # load tutorial created in the test_installTutorial function
+ vault = Vault()
+ reloaded_tuto = vault.loadTutorial(self.tuto_guid)
+
+ # Compare the two FSMs
+ assert reloaded_tuto.get_state_dict().keys() == self.fsm.get_state_dict().keys(), \
+ 'FSM underlying dictionary differ from original to reformed one'
+
+ def test_saveTutorial(self):
+ """
+ This test verify the vault function that save a new tutorial (Tutorial object +metadata).
+ """
+
+ # Save the tutorial in the vault
+ vault = Vault()
+ tutorial = self.fsm
+ vault.saveTutorial(tutorial, self.test_metadata_dict)
+
+ # Get the tutorial back
+ reloaded_tuto = vault.loadTutorial(self.save_test_guid)
+
+ # Compare the two FSMs
+ assert reloaded_tuto.get_state_dict().keys() == self.fsm.get_state_dict().keys(), \
+ 'FSM underlying dictionary differ from original to reformed one'
+
+ # TODO : Compare the initial and reloaded metadata when vault.Query() will accept specifc argument
+ # (so we can specifiy that we want only the metadata for this particular tutorial
+
+
+ def test_add_delete_get_path_ressource(self):
+ """
+ This test verify that the vault interface function add_ressource succesfully add ressource in the vault
+ and return the new ressource id. It also test the deletion of the ressource.
+ """
+ # Path of an image file in the test folder
+ image_path = os.path.join(os.getcwd(), 'tests', 'ressources', 'icon.svg')
+ assert os.path.isfile(image_path), 'cannot find the test image file'
+
+ # Create and save a tutorial
+ Vault.saveTutorial(self.fsm, self.test_metadata_dict)
+
+ bundler = TutorialBundler(self.save_test_guid)
+ tuto_path = bundler.get_tutorial_path(self.save_test_guid)
+
+ # add the ressource to the tutorial
+ ressource_id = Vault.add_resource(self.save_test_guid, image_path)
+
+ # Check that the image file is now in the vault
+ assert os.path.isfile(os.path.join(tuto_path, 'ressources', ressource_id)), 'image file not found in vault'
+
+ # Check if get_ressource_path Vault interface function is working
+ vault_path = Vault.get_resource_path(self.save_test_guid, ressource_id)
+
+ assert os.path.isfile(vault_path), 'path returned is not a file'
+ basename, extension = os.path.splitext(vault_path)
+ assert extension == '.svg', 'wrong file path have been returned'
+
+
+ # Delete the ressource
+ Vault.delete_resource(self.save_test_guid, ressource_id)
+
+ # Check that the ressource is not in the vault anymore
+ assert os.path.isfile(os.path.join(tuto_path, 'ressources', ressource_id)) == False, 'image file found in vault when it should have been deleted.'
+
+ def tearDown(self):
+ folder = os.path.join(os.getenv("HOME"),".sugar", 'default', 'tutorius', 'data');
+ for file in os.listdir(folder):
+ file_path = os.path.join(folder, file)
+ shutil.rmtree(file_path)
+
+ if (os.path.isdir(os.path.join(os.getenv("HOME"),".sugar", 'default', 'tutorius', 'tmp'))):
+ shutil.rmtree(os.path.join(os.getenv("HOME"),".sugar", 'default', 'tutorius', 'tmp'))
+
+
+class SerializerInterfaceTest(unittest.TestCase):
+ """
+ For completeness' sake.
+ """
+
+ def test_save(self):
+ ser = Serializer()
+
+ try:
+ ser.save_tutorial(None)
+ assert False, "save_tutorial() should throw an unimplemented error"
+ except:
+ pass
+
+ def test_load(self):
+ ser = Serializer()
+
+ try:
+ ser.load_tutorial(str(uuid.uuid1()))
+ assert False, "load_tutorial() should throw an unimplemented error"
+ except:
+ pass
+
+class XMLSerializerTest(unittest.TestCase):
+ """
+ Tests the transformation of XML to FSM, then back.
+ """
+
+ def setUp(self):
+ # Create the sample FSM
+ self.fsm = Tutorial("TestTutorial1")
+
+ # Add a few states
+ act1 = addon.create('BubbleMessage', message="Hi", position=[300, 450])
+ ev1 = addon.create('GtkWidgetEventFilter', "0.12.31.2.2", "clicked")
+ act2 = addon.create('BubbleMessage', message="Second message", position=[250, 150], tail_pos=[1,2])
+
+ self.fsm.add_action("INIT",act1)
+ st2 = self.fsm.add_state((act2,))
+ self.fsm.add_transition("INIT",(ev1, st2))
+
+ self.uuid = uuid1()
+
+ def tearDown(self):
+ """
+ Nothing to do anymore.
+ """
+ pass
+
+ def create_test_metadata(self, file_obj, guid):
+ file_obj.write("[GENERAL_METADATA]\n")
+ file_obj.write('guid=' + str(guid) + '\n')
+ file_obj.write('name=TestTutorial1\n')
+ file_obj.write('version=1\n')
+ file_obj.write('description=This is a test tutorial 1\n')
+ file_obj.write('rating=3.5\n')
+ file_obj.write('category=Test\n')
+ file_obj.write('publish_state=false\n')
+ file_obj.write('[RELATED_ACTIVITIES]\n')
+ file_obj.write('org.laptop.TutoriusActivity = 1\n')
+ file_obj.write('org.laptop.Writus = 1\n')
+
+ def test_save_and_load(self):
+ """
+ Writes an FSM to disk, then compares the file to the expected results.
+ Load up the written FSM and compare it with the object representation.
+ """
+ xml_ser = XMLSerializer()
+ tuto_file = cStringIO.StringIO()
+ xml_ser.save_tutorial(self.fsm, tuto_file)
+
+ xml_ser = XMLSerializer()
+ load_tuto_file = cStringIO.StringIO(tuto_file.getvalue())
+ loaded_fsm = xml_ser.load_tutorial(load_tuto_file)
+
+ # Compare the two FSMs
+ assert loaded_fsm == self.fsm, "Loaded FSM differs from original one"
+
+ def test_all_actions(self):
+ """
+ Inserts all the known action types in a FSM, then attempt to load it.
+ """
+ fsm = Tutorial("TestActions")
+ tuto_file = cStringIO.StringIO()
+ act1 = addon.create('BubbleMessage', "Hi!", position=[10,120], tail_pos=[-12,30])
+ act2 = addon.create('DialogMessage', "Hello again.", position=[120,10])
+ act3 = addon.create('WidgetIdentifyAction')
+ act4 = addon.create('DisableWidgetAction', "0.0.0.1.0.0.0")
+ act5 = addon.create('TypeTextAction', "0.0.0.1.1.1.0.0", "New text")
+ act6 = addon.create('ClickAction', "0.0.1.0.1.1")
+ act7 = addon.create('OnceWrapper', action=act1)
+ act8 = addon.create('ChainAction', actions=[act1, act2, act3, act4])
+ actions = [act1, act2, act3, act4, act5, act6, act7, act8]
+
+ for action in actions:
+ fsm.add_action("INIT", action)
+
+ xml_ser = XMLSerializer()
+ xml_ser.save_tutorial(fsm, tuto_file)
+ load_tuto_file = cStringIO.StringIO(tuto_file.getvalue())
+
+ reloaded_fsm = xml_ser.load_tutorial(load_tuto_file)
+ # Compare the two FSMs
+ assert reloaded_fsm == fsm, "Loaded FSM differs from original one"
+ assert fsm.get_action_dict() == reloaded_fsm.get_action_dict(), \
+ "Actions should be the same"
+
+ def test_all_filters(self):
+ """
+ Inserts all the known action filters in a FSM, then attempt to load it.
+ """
+ fsm = Tutorial("TestFilters")
+ tuto_file = cStringIO.StringIO()
+
+ ev1 = addon.create('TimerEvent', 1000)
+ ev2 = addon.create('GtkWidgetEventFilter', object_id="0.0.1.1.0.0.1", event_name="clicked")
+ ev3 = addon.create('GtkWidgetTypeFilter', "0.0.1.1.1.2.3", text="Typed stuff")
+ ev4 = addon.create('GtkWidgetTypeFilter', "0.0.1.1.1.2.3", strokes="acbd")
+ filters = [ev1, ev2, ev3, ev4]
+
+ for efilter in filters:
+ fsm.add_transition("INIT", (efilter, 'END'))
+
+ xml_ser = XMLSerializer()
+ xml_ser.save_tutorial(fsm, tuto_file)
+ load_tuto_file = cStringIO.StringIO(tuto_file.getvalue())
+
+ reloaded_fsm = xml_ser.load_tutorial(load_tuto_file)
+ # Compare the two FSMs
+ assert reloaded_fsm == fsm, "Loaded FSM differs from original one"
+ assert fsm.get_transition_dict() == reloaded_fsm.get_transition_dict(), \
+ "Transitions should be the same"
+
+
+class TutorialBundlerTests(unittest.TestCase):
+ """
+ TutorialBundler tests
+
+ This module contains all the tests for the storage mecanisms for tutorials
+ This mean testing saving and loading tutorial, .ini file management and
+ adding ressources to tutorial
+ """
+
+ def setUp(self):
+ os.environ["SUGAR_BUNDLE_PATH"] = os.path.join(sugar.tutorius.vault._get_store_root(), 'test_bundle_path')
+ path = os.path.join(sugar.tutorius.vault._get_store_root(), 'test_bundle_path')
+ if os.path.isdir(path) != True:
+ os.makedirs(path)
+
+ #generate a test GUID
+ self.test_guid = uuid1()
+ self.guid_path = os.path.join(sugar.tutorius.vault._get_store_root(),str(self.test_guid))
+ os.mkdir(self.guid_path)
+
+ self.ini_file = os.path.join(self.guid_path, "meta.ini")
+
+ ini_file = open(self.ini_file, 'wt')
+ ini_file.write('[GENERAL_METADATA]\n')
+ ini_file.write('guid=' + str(self.test_guid) + '\n')
+ ini_file.write('name=TestTutorial\n')
+ ini_file.write('version=1\n')
+ ini_file.write('description=This is a test tutorial\n')
+ ini_file.write('rating=3.5\n')
+ ini_file.write('category=Test\n')
+ ini_file.write('publish_state=false\n')
+ ini_file.write('[RELATED_ACTIVITES]\n')
+ ini_file.write('org.laptop.TutoriusActivity = 1\n')
+ ini_file.write('org.laptop.Writus = 1\n')
+ ini_file.close()
+
+ def tearDown(self):
+ os.remove(self.ini_file)
+ os.rmdir(self.guid_path)
+
+ folder = os.path.join(os.getenv("HOME"),".sugar", 'default', 'tutorius', 'data');
+ for file in os.listdir(folder):
+ file_path = os.path.join(folder, file)
+ shutil.rmtree(file_path)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tutorius/TProbe.py b/tutorius/TProbe.py
index 9d19f1b..08c9651 100644
--- a/tutorius/TProbe.py
+++ b/tutorius/TProbe.py
@@ -8,12 +8,12 @@ import dbus
import dbus.service
import cPickle as pickle
-import sugar.tutorius.addon as addon
-from sugar.tutorius.services import ObjectStore
-from sugar.tutorius.properties import TPropContainer
+from . import addon
+from .services import ObjectStore
+from .properties import TPropContainer
-from sugar.tutorius.dbustools import remote_call, save_args
+from .dbustools import remote_call, save_args
import copy
"""
@@ -38,45 +38,65 @@ class TProbe(dbus.service.Object):
a DBUS Interface.
"""
- def __init__(self, activity_name, activity):
+ def __init__(self, activity, service_proxy=None):
"""
Create and register a TProbe for an activity.
- @param activity_name unique activity_id
@param activity activity reference, must be a gtk container
+ @param service_proxy
"""
- LOGGER.debug("TProbe :: Creating TProbe for %s (%d)", activity_name, os.getpid())
- LOGGER.debug("TProbe :: Current gobject context: %s", str(gobject.main_context_default()))
- LOGGER.debug("TProbe :: Current gobject depth: %s", str(gobject.main_depth()))
# Moving the ObjectStore assignment here, in the meantime
# the reference to the activity shouldn't be share as a
# global variable but passed by the Probe to the objects
# that requires it
self._activity = activity
+
+ if service_proxy == None:
+ from .service import ServiceProxy
+
+ self._service_proxy = service_proxy or ServiceProxy()
ObjectStore().activity = activity
- self._activity_name = activity_name
+ self._activity_name = activity.get_bundle_id()
+ self._unique_id = activity.get_id()
+
+ LOGGER.debug("TProbe :: Creating TProbe for %s (%d)", self._activity_name, os.getpid())
+ LOGGER.debug("TProbe :: Current gobject context: %s", str(gobject.main_context_default()))
+ LOGGER.debug("TProbe :: Current gobject depth: %s", str(gobject.main_depth()))
self._session_bus = dbus.SessionBus()
# Giving a new name because _name is already used by dbus
- self._name2 = dbus.service.BusName(activity_name, self._session_bus)
- dbus.service.Object.__init__(self, self._session_bus, "/tutorius/Probe")
+ self._name2 = dbus.service.BusName(self._activity_name, self._session_bus)
+ dbus.service.Object.__init__(self, self._session_bus, "/tutorius/Probe/"+str(self._unique_id))
# Add the dictionary we will use to store which actions and events
# are known
self._installedActions = {}
self._subscribedEvents = {}
+ LOGGER.debug("TProbe :: registering '%s' with unique_id '%s'", self._activity_name, activity.get_id())
+ self._service_proxy.register_probe(self._activity_name, self._unique_id)
+
+
+
def start(self):
"""
Optional method to call if the probe is not inserted into an
existing activity. Starts a gobject mainloop
"""
mainloop = gobject.MainLoop()
- print "Starting Probe for " + self._activity_name
mainloop.run()
+ def stop(self):
+ """
+ Clean up the probe when finished. Should be called just
+ before a process ends
+ """
+ from .service import ServiceProxy
+ LOGGER.debug("TProbe :: unregistering '%s' with unique_id '%s'", self._activity_name, self._unique_id)
+ ServiceProxy().unregister_probe(self._unique_id)
+
@dbus.service.method("org.tutorius.ProbeInterface",
in_signature='s', out_signature='')
def registered(self, service):
@@ -195,7 +215,11 @@ class TProbe(dbus.service.Object):
# The actual method we will call on the probe to send events
def notify(self, event):
LOGGER.debug("TProbe :: notify event %s", str(event))
- self.eventOccured(pickle.dumps(event))
+ #Check that this event is even allowed
+ if event in self._subscribedEvents.values():
+ self.eventOccured(pickle.dumps(event))
+ else:
+ raise RuntimeWarning("Attempted to raise an unregistered event")
# Return a unique name for this action
def _generate_action_reference(self, action):
@@ -230,16 +254,17 @@ class ProbeProxy:
It provides an object interface to the TProbe, which requires pickled
strings, across a DBus communication.
"""
- def __init__(self, activityName):
+ def __init__(self, activityName, unique_id):
"""
Constructor
- @param activityName unique activity id. Must be a valid dbus bus name.
+ @param activityName generic activity name. Must be a valid dbus bus name.
+ @param unique_id unique id specific to an instance of an activity
"""
LOGGER.debug("ProbeProxy :: Creating ProbeProxy for %s (%d)", activityName, os.getpid())
LOGGER.debug("ProbeProxy :: Current gobject context: %s", str(gobject.main_context_default()))
LOGGER.debug("ProbeProxy :: Current gobject depth: %s", str(gobject.main_depth()))
bus = dbus.SessionBus()
- self._object = bus.get_object(activityName, "/tutorius/Probe")
+ self._object = bus.get_object(activityName, "/tutorius/Probe/"+str(unique_id))
self._probe = dbus.Interface(self._object, "org.tutorius.ProbeInterface")
self._actions = {}
@@ -259,7 +284,7 @@ class ProbeProxy:
LOGGER.debug("ProbeProxy :: Currently %d events registered", len(self._registeredCallbacks))
if self._registeredCallbacks.has_key(event):
- for callback in self._registeredCallbacks[event].itervalues():
+ for callback in self._registeredCallbacks[event].values():
callback(event)
else:
for event in self._registeredCallbacks.keys():
@@ -273,6 +298,7 @@ class ProbeProxy:
return False
def __update_action(self, action, address):
+ LOGGER.debug("ProbeProxy :: Updating action %s with address %s", str(action), str(address))
self._actions[action] = str(address)
def __clear_action(self, action):
@@ -351,11 +377,9 @@ class ProbeProxy:
if self._registeredCallbacks.has_key(event)\
and self._registeredCallbacks[event].has_key(address):
- LOGGER.debug("ProbeProxy :: POP ")
self._registeredCallbacks[event].pop(address)
if self._registeredCallbacks[event] == {}:
- LOGGER.debug("ProbeProxy :: POP2 ")
self._registeredCallbacks.pop(event)
self._subscribedEvents.pop(address)
@@ -389,22 +413,20 @@ class ProbeProxy:
@return None
"""
LOGGER.debug("ProbeProxy :: Unregister adress %s issued", str(address))
- if not block:
- raise RuntimeError("This function does not allow non-blocking mode yet")
if address in self._subscribedEvents.keys():
remote_call(self._probe.unsubscribe, (address,),
return_cb=save_args(self.__clear_event, address),
block=block)
else:
- LOGGER.debug("ProbeProxy :: unsubsribe address %s failed : not registered", address)
+ LOGGER.debug("ProbeProxy :: unsubscribe address %s failed : not registered", address)
def detach(self, block=False):
"""
Detach the ProbeProxy from it's TProbe. All installed actions and
subscribed events should be removed.
"""
- for action in self._actions.keys():
- self.uninstall(action, block)
+ for action_addr in self._actions.keys():
+ self.uninstall(action_addr, block)
for address in self._subscribedEvents.keys():
self.unsubscribe(address, block)
@@ -417,10 +439,22 @@ class ProbeManager(object):
For now, it only handles one at a time, though.
Actually it doesn't do much at all. But it keeps your encapsulation happy
"""
- def __init__(self):
+ _LOGGER = logging.getLogger("sugar.tutorius.ProbeManager")
+
+ def __init__(self, proxy_class=ProbeProxy):
+ """Constructor
+ @param proxy_class Class to use for creating Proxies to activities.
+ The class should support the same interface as ProbeProxy. Exists
+ to make this class unit-testable by replacing the Proxy with a mock
+ """
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
+
+ self._ProxyClass = proxy_class
self._probes = {}
self._current_activity = None
+ ProbeManager._LOGGER.debug("__init__()")
+
def setCurrentActivity(self, activity_id):
if not activity_id in self._probes:
raise RuntimeError("Activity not attached")
@@ -430,21 +464,6 @@ class ProbeManager(object):
return self._current_activity
currentActivity = property(fget=getCurrentActivity, fset=setCurrentActivity)
- def attach(self, activity_id):
- if activity_id in self._probes:
- raise RuntimeWarning("Activity already attached")
-
- self._probes[activity_id] = ProbeProxy(activity_id)
- #TODO what do we do with this? Raise something?
- if self._probes[activity_id].isAlive():
- print "Alive!"
- else:
- print "FAil!"
-
- def detach(self, activity_id):
- if activity_id in self._probes:
- probe = self._probes.pop(activity_id)
- probe.detach()
def install(self, action, block=False):
"""
@@ -454,7 +473,7 @@ class ProbeManager(object):
@return None
"""
if self.currentActivity:
- return self._probes[self.currentActivity].install(action, block)
+ return self._first_proxy(self.currentActivity).install(action, block)
else:
raise RuntimeWarning("No activity attached")
@@ -467,7 +486,7 @@ class ProbeManager(object):
@return None
"""
if self.currentActivity:
- return self._probes[self.currentActivity].update(action, newaction, block)
+ return self._first_proxy(self.currentActivity).update(action, newaction, block)
else:
raise RuntimeWarning("No activity attached")
@@ -478,7 +497,7 @@ class ProbeManager(object):
@param block Force a synchroneous dbus call if True
"""
if self.currentActivity:
- return self._probes[self.currentActivity].uninstall(action, block)
+ return self._first_proxy(self.currentActivity).uninstall(action, block)
else:
raise RuntimeWarning("No activity attached")
@@ -490,7 +509,7 @@ class ProbeManager(object):
@return address identifier used for unsubscribing
"""
if self.currentActivity:
- return self._probes[self.currentActivity].subscribe(event, callback)
+ return self._first_proxy(self.currentActivity).subscribe(event, callback)
else:
raise RuntimeWarning("No activity attached")
@@ -501,7 +520,67 @@ class ProbeManager(object):
@return None
"""
if self.currentActivity:
- return self._probes[self.currentActivity].unsubscribe(address)
+ return self._first_proxy(self.currentActivity).unsubscribe(address)
else:
raise RuntimeWarning("No activity attached")
+ def register_probe(self, process_name, unique_id):
+ """ Adds a probe to the known probes, to be used by a tutorial.
+
+ A generic name for a process (like an Activity) is passed
+ so that the execution of a tutorial will use that generic
+ name. However, a unique id is also passed to differentiate
+ between many instances of the same process.
+
+ @param process_name The generic name of a process
+ @param unique_id The unique identification associated to this
+ process
+ """
+ ProbeManager._LOGGER.debug("register_probe(%s,%s)", process_name, unique_id)
+ if process_name not in self._probes:
+ self._probes[process_name] = [(unique_id,self._ProxyClass(process_name, unique_id))]
+ else:
+ self._probes[process_name].append((unique_id,self._ProxyClass(process_name, unique_id)))
+
+
+ def unregister_probe(self, unique_id):
+ """ Remove a probe from the known probes.
+
+ @param unique_id The unique identification associated to this
+ process
+ """
+ ProbeManager._LOGGER.debug("unregister_probe(%s)", unique_id)
+ for process_name, proxies in self._probes.items():
+ for id, proxy in proxies:
+ if unique_id == id:
+ proxy.detach()
+ proxies.remove((id,proxy))
+ if len(proxies) == 0:
+ self._probes.pop(process_name)
+
+ def get_registered_probes_list(self, process_name=None):
+ if process_name == None:
+ probe_list = []
+ for probes in self._probes.itervalues():
+ probe_list.extend(probes)
+ return probe_list
+ else:
+ if process_name in self._probes:
+ return self._probes[process_name]
+ else:
+ return []
+
+
+
+ def _first_proxy(self, process_name):
+ """
+ Returns the oldest probe connected under the process_name
+ @param process_name The generic process name under which the probe
+ is connected
+ """
+ if process_name in self._probes:
+ return self._probes[process_name][0][1]
+ else:
+ raise RuntimeWarning("No activity attached under '%s'", process_name)
+
+
diff --git a/tutorius/actions.py b/tutorius/actions.py
index 4269cd7..d5a8641 100644
--- a/tutorius/actions.py
+++ b/tutorius/actions.py
@@ -16,16 +16,15 @@
"""
This module defines Actions that can be done and undone on a state
"""
+import gtk
+
from gettext import gettext as _
-from sugar.tutorius import gtkutils, addon
-from dialog import TutoriusDialog
-import overlayer
-from sugar.tutorius.editor import WidgetIdentifier
-from sugar.tutorius.services import ObjectStore
-from sugar.tutorius.properties import *
from sugar.graphics import icon
-import gtk.gdk
+
+from . import addon
+from .services import ObjectStore
+from .properties import *
class DragWrapper(object):
"""Wrapper to allow gtk widgets to be dragged around"""
@@ -82,6 +81,7 @@ class DragWrapper(object):
"""Callback for end of drag (stolen focus)."""
self._dragging = False
+
def set_draggable(self, value):
"""Setter for the draggable property"""
if bool(value) ^ bool(self._drag_on):
@@ -176,149 +176,4 @@ class Action(TPropContainer):
x, y = self._drag.position
self.position = [int(x), int(y)]
self.__edit_img.destroy()
-
-class OnceWrapper(Action):
- """
- Wraps a class to perform an action once only
-
- This ConcreteActions's do() method will only be called on the first do()
- and the undo() will be callable after do() has been called
- """
-
- _action = TAddonProperty()
-
- def __init__(self, action):
- Action.__init__(self)
- self._called = False
- self._need_undo = False
- self._action = action
-
- def do(self):
- """
- Do the action only on the first time
- """
- if not self._called:
- self._called = True
- self._action.do()
- self._need_undo = True
-
- def undo(self):
- """
- Undo the action if it's been done
- """
- if self._need_undo:
- self._action.undo()
- self._need_undo = False
-
-class WidgetIdentifyAction(Action):
- def __init__(self):
- Action.__init__(self)
- self.activity = None
- self._dialog = None
-
- def do(self):
- os = ObjectStore()
- if os.activity:
- self.activity = os.activity
-
- self._dialog = WidgetIdentifier(self.activity)
- self._dialog.show()
-
-
- def undo(self):
- if self._dialog:
- self._dialog.destroy()
-
-class ChainAction(Action):
- """Utility class to allow executing actions in a specific order"""
- def __init__(self, *actions):
- """ChainAction(action1, ... ) builds a chain of actions"""
- Action.__init__(self)
- self._actions = actions
-
- def do(self,**kwargs):
- """do() each action in the chain"""
- for act in self._actions:
- act.do(**kwargs)
-
- def undo(self):
- """undo() each action in the chain, starting with the last"""
- for act in reversed(self._actions):
- act.undo()
-
-class DisableWidgetAction(Action):
- def __init__(self, target):
- """Constructor
- @param target target treeish
- """
- Action.__init__(self)
- self._target = target
- self._widget = None
-
- def do(self):
- """Action do"""
- os = ObjectStore()
- if os.activity:
- self._widget = gtkutils.find_widget(os.activity, self._target)
- if self._widget:
- self._widget.set_sensitive(False)
-
- def undo(self):
- """Action undo"""
- if self._widget:
- self._widget.set_sensitive(True)
-
-
-class TypeTextAction(Action):
- """
- Simulate a user typing text in a widget
- Work on any widget that implements a insert_text method
-
- @param widget The treehish representation of the widget
- @param text the text that is typed
- """
- def __init__(self, widget, text):
- Action.__init__(self)
-
- self._widget = widget
- self._text = text
-
- def do(self, **kwargs):
- """
- Type the text
- """
- widget = gtkutils.find_widget(ObjectStore().activity, self._widget)
- if hasattr(widget, "insert_text"):
- widget.insert_text(self._text, -1)
-
- def undo(self):
- """
- no undo
- """
- pass
-
-class ClickAction(Action):
- """
- Action that simulate a click on a widget
- Work on any widget that implements a clicked() method
-
- @param widget The threehish representation of the widget
- """
- def __init__(self, widget):
- Action.__init__(self)
- self._widget = widget
-
- def do(self):
- """
- click the widget
- """
- widget = gtkutils.find_widget(ObjectStore().activity, self._widget)
- if hasattr(widget, "clicked"):
- widget.clicked()
- def undo(self):
- """
- No undo
- """
- pass
-
diff --git a/tutorius/addon.py b/tutorius/addon.py
index 51791d1..7ac68f7 100644
--- a/tutorius/addon.py
+++ b/tutorius/addon.py
@@ -38,6 +38,9 @@ import logging
PREFIX = __name__+"s"
PATH = re.sub("addon\\.py[c]$", "", __file__)+"addons"
+TYPE_ACTION = 'action'
+TYPE_EVENT = 'event'
+
_cache = None
def _reload_addons():
@@ -47,16 +50,23 @@ def _reload_addons():
mod = __import__(PREFIX+'.'+re.sub("\\.py$", "", addon), {}, {}, [""])
if hasattr(mod, "__action__"):
_cache[mod.__action__['name']] = mod.__action__
+ mod.__action__['type'] = TYPE_ACTION
continue
if hasattr(mod, "__event__"):
_cache[mod.__event__['name']] = mod.__event__
+ mod.__event__['type'] = TYPE_EVENT
def create(name, *args, **kwargs):
global _cache
if not _cache:
_reload_addons()
try:
- return _cache[name]['class'](*args, **kwargs)
+ comp_metadata = _cache[name]
+ try:
+ return comp_metadata['class'](*args, **kwargs)
+ except:
+ logging.error("Could not instantiate %s with parameters %s, %s"%(comp_metadata['name'],str(args), str(kwargs)))
+ return None
except KeyError:
logging.error("Addon not found for class '%s'", name)
return None
@@ -73,4 +83,13 @@ def get_addon_meta(name):
_reload_addons()
return _cache[name]
+def get_name_from_type(typ):
+ global _cache
+ if not _cache:
+ _reload_addons()
+ for addon in _cache.keys():
+ if typ == _cache[addon]['class']:
+ return addon
+ return None
+
# vim:set ts=4 sts=4 sw=4 et:
diff --git a/tutorius/apilib/__init__.py b/tutorius/apilib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tutorius/apilib/__init__.py
diff --git a/tutorius/apilib/__init__.pyc b/tutorius/apilib/__init__.pyc
new file mode 100644
index 0000000..bd4346b
--- /dev/null
+++ b/tutorius/apilib/__init__.pyc
Binary files differ
diff --git a/tutorius/apilib/httplib2/.svn/all-wcprops b/tutorius/apilib/httplib2/.svn/all-wcprops
new file mode 100644
index 0000000..601feb8
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/all-wcprops
@@ -0,0 +1,29 @@
+K 25
+svn:wc:ra_dav:version-url
+V 30
+/svn/!svn/ver/2/trunk/httplib2
+END
+iri2uri.pyc
+K 25
+svn:wc:ra_dav:version-url
+V 42
+/svn/!svn/ver/2/trunk/httplib2/iri2uri.pyc
+END
+__init__.py
+K 25
+svn:wc:ra_dav:version-url
+V 42
+/svn/!svn/ver/2/trunk/httplib2/__init__.py
+END
+__init__.pyc
+K 25
+svn:wc:ra_dav:version-url
+V 43
+/svn/!svn/ver/2/trunk/httplib2/__init__.pyc
+END
+iri2uri.py
+K 25
+svn:wc:ra_dav:version-url
+V 41
+/svn/!svn/ver/2/trunk/httplib2/iri2uri.py
+END
diff --git a/tutorius/apilib/httplib2/.svn/entries b/tutorius/apilib/httplib2/.svn/entries
new file mode 100644
index 0000000..1a3c5d2
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/entries
@@ -0,0 +1,66 @@
+8
+
+dir
+2
+https://python-rest-client.googlecode.com/svn/trunk/httplib2
+https://python-rest-client.googlecode.com/svn
+
+
+
+2008-05-14T17:00:19.245332Z
+2
+bosteen
+
+
+svn:special svn:externals svn:needs-lock
+
+iri2uri.pyc
+file
+
+
+
+
+2008-05-14T14:48:03.000000Z
+6f9a0833a6dc59c42b7aec0dfdf39dd0
+2008-05-14T17:00:19.245332Z
+2
+bosteen
+has-props
+
+__init__.py
+file
+
+
+
+
+2007-10-23T15:25:46.000000Z
+00c607566b698248d5a5c40508843cd7
+2008-05-14T17:00:19.245332Z
+2
+bosteen
+
+__init__.pyc
+file
+
+
+
+
+2008-05-14T14:48:03.000000Z
+bdf8607edad61c67d890de558db8006c
+2008-05-14T17:00:19.245332Z
+2
+bosteen
+has-props
+
+iri2uri.py
+file
+
+
+
+
+2007-09-04T04:02:06.000000Z
+c0f9c5cb229a22e21575322b4ba77741
+2008-05-14T17:00:19.245332Z
+2
+bosteen
+
diff --git a/tutorius/apilib/httplib2/.svn/format b/tutorius/apilib/httplib2/.svn/format
new file mode 100644
index 0000000..45a4fb7
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/format
@@ -0,0 +1 @@
+8
diff --git a/tutorius/apilib/httplib2/.svn/prop-base/__init__.pyc.svn-base b/tutorius/apilib/httplib2/.svn/prop-base/__init__.pyc.svn-base
new file mode 100644
index 0000000..5e9587e
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/prop-base/__init__.pyc.svn-base
@@ -0,0 +1,5 @@
+K 13
+svn:mime-type
+V 24
+application/octet-stream
+END
diff --git a/tutorius/apilib/httplib2/.svn/prop-base/iri2uri.pyc.svn-base b/tutorius/apilib/httplib2/.svn/prop-base/iri2uri.pyc.svn-base
new file mode 100644
index 0000000..5e9587e
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/prop-base/iri2uri.pyc.svn-base
@@ -0,0 +1,5 @@
+K 13
+svn:mime-type
+V 24
+application/octet-stream
+END
diff --git a/tutorius/apilib/httplib2/.svn/text-base/__init__.py.svn-base b/tutorius/apilib/httplib2/.svn/text-base/__init__.py.svn-base
new file mode 100644
index 0000000..982bf8a
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/text-base/__init__.py.svn-base
@@ -0,0 +1,1123 @@
+from __future__ import generators
+"""
+httplib2
+
+A caching http interface that supports ETags and gzip
+to conserve bandwidth.
+
+Requires Python 2.3 or later
+
+Changelog:
+2007-08-18, Rick: Modified so it's able to use a socks proxy if needed.
+
+"""
+
+__author__ = "Joe Gregorio (joe@bitworking.org)"
+__copyright__ = "Copyright 2006, Joe Gregorio"
+__contributors__ = ["Thomas Broyer (t.broyer@ltgt.net)",
+ "James Antill",
+ "Xavier Verges Farrero",
+ "Jonathan Feinberg",
+ "Blair Zajac",
+ "Sam Ruby",
+ "Louis Nyffenegger"]
+__license__ = "MIT"
+__version__ = "$Rev: 259 $"
+
+import re
+import sys
+import md5
+import email
+import email.Utils
+import email.Message
+import StringIO
+import gzip
+import zlib
+import httplib
+import urlparse
+import base64
+import os
+import copy
+import calendar
+import time
+import random
+import sha
+import hmac
+from gettext import gettext as _
+import socket
+
+try:
+ import socks
+except ImportError:
+ socks = None
+
+if sys.version_info >= (2,3):
+ from iri2uri import iri2uri
+else:
+ def iri2uri(uri):
+ return uri
+
+__all__ = ['Http', 'Response', 'ProxyInfo', 'HttpLib2Error',
+ 'RedirectMissingLocation', 'RedirectLimit', 'FailedToDecompressContent',
+ 'UnimplementedDigestAuthOptionError', 'UnimplementedHmacDigestAuthOptionError',
+ 'debuglevel']
+
+
+# The httplib debug level, set to a non-zero value to get debug output
+debuglevel = 0
+
+# Python 2.3 support
+if sys.version_info < (2,4):
+ def sorted(seq):
+ seq.sort()
+ return seq
+
+# Python 2.3 support
+def HTTPResponse__getheaders(self):
+ """Return list of (header, value) tuples."""
+ if self.msg is None:
+ raise httplib.ResponseNotReady()
+ return self.msg.items()
+
+if not hasattr(httplib.HTTPResponse, 'getheaders'):
+ httplib.HTTPResponse.getheaders = HTTPResponse__getheaders
+
+# All exceptions raised here derive from HttpLib2Error
+class HttpLib2Error(Exception): pass
+
+# Some exceptions can be caught and optionally
+# be turned back into responses.
+class HttpLib2ErrorWithResponse(HttpLib2Error):
+ def __init__(self, desc, response, content):
+ self.response = response
+ self.content = content
+ HttpLib2Error.__init__(self, desc)
+
+class RedirectMissingLocation(HttpLib2ErrorWithResponse): pass
+class RedirectLimit(HttpLib2ErrorWithResponse): pass
+class FailedToDecompressContent(HttpLib2ErrorWithResponse): pass
+class UnimplementedDigestAuthOptionError(HttpLib2ErrorWithResponse): pass
+class UnimplementedHmacDigestAuthOptionError(HttpLib2ErrorWithResponse): pass
+
+class RelativeURIError(HttpLib2Error): pass
+class ServerNotFoundError(HttpLib2Error): pass
+
+# Open Items:
+# -----------
+# Proxy support
+
+# Are we removing the cached content too soon on PUT (only delete on 200 Maybe?)
+
+# Pluggable cache storage (supports storing the cache in
+# flat files by default. We need a plug-in architecture
+# that can support Berkeley DB and Squid)
+
+# == Known Issues ==
+# Does not handle a resource that uses conneg and Last-Modified but no ETag as a cache validator.
+# Does not handle Cache-Control: max-stale
+# Does not use Age: headers when calculating cache freshness.
+
+
+# The number of redirections to follow before giving up.
+# Note that only GET redirects are automatically followed.
+# Will also honor 301 requests by saving that info and never
+# requesting that URI again.
+DEFAULT_MAX_REDIRECTS = 5
+
+# Which headers are hop-by-hop headers by default
+HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade']
+
+def _get_end2end_headers(response):
+ hopbyhop = list(HOP_BY_HOP)
+ hopbyhop.extend([x.strip() for x in response.get('connection', '').split(',')])
+ return [header for header in response.keys() if header not in hopbyhop]
+
+URI = re.compile(r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?")
+
+def parse_uri(uri):
+ """Parses a URI using the regex given in Appendix B of RFC 3986.
+
+ (scheme, authority, path, query, fragment) = parse_uri(uri)
+ """
+ groups = URI.match(uri).groups()
+ return (groups[1], groups[3], groups[4], groups[6], groups[8])
+
+def urlnorm(uri):
+ (scheme, authority, path, query, fragment) = parse_uri(uri)
+ if not scheme or not authority:
+ raise RelativeURIError("Only absolute URIs are allowed. uri = %s" % uri)
+ authority = authority.lower()
+ scheme = scheme.lower()
+ if not path:
+ path = "/"
+ # Could do syntax based normalization of the URI before
+ # computing the digest. See Section 6.2.2 of Std 66.
+ request_uri = query and "?".join([path, query]) or path
+ scheme = scheme.lower()
+ defrag_uri = scheme + "://" + authority + request_uri
+ return scheme, authority, request_uri, defrag_uri
+
+
+# Cache filename construction (original borrowed from Venus http://intertwingly.net/code/venus/)
+re_url_scheme = re.compile(r'^\w+://')
+re_slash = re.compile(r'[?/:|]+')
+
+def safename(filename):
+ """Return a filename suitable for the cache.
+
+ Strips dangerous and common characters to create a filename we
+ can use to store the cache in.
+ """
+
+ try:
+ if re_url_scheme.match(filename):
+ if isinstance(filename,str):
+ filename = filename.decode('utf-8')
+ filename = filename.encode('idna')
+ else:
+ filename = filename.encode('idna')
+ except UnicodeError:
+ pass
+ if isinstance(filename,unicode):
+ filename=filename.encode('utf-8')
+ filemd5 = md5.new(filename).hexdigest()
+ filename = re_url_scheme.sub("", filename)
+ filename = re_slash.sub(",", filename)
+
+ # limit length of filename
+ if len(filename)>200:
+ filename=filename[:200]
+ return ",".join((filename, filemd5))
+
+NORMALIZE_SPACE = re.compile(r'(?:\r\n)?[ \t]+')
+def _normalize_headers(headers):
+ return dict([ (key.lower(), NORMALIZE_SPACE.sub(value, ' ').strip()) for (key, value) in headers.iteritems()])
+
+def _parse_cache_control(headers):
+ retval = {}
+ if headers.has_key('cache-control'):
+ parts = headers['cache-control'].split(',')
+ parts_with_args = [tuple([x.strip() for x in part.split("=")]) for part in parts if -1 != part.find("=")]
+ parts_wo_args = [(name.strip(), 1) for name in parts if -1 == name.find("=")]
+ retval = dict(parts_with_args + parts_wo_args)
+ return retval
+
+# Whether to use a strict mode to parse WWW-Authenticate headers
+# Might lead to bad results in case of ill-formed header value,
+# so disabled by default, falling back to relaxed parsing.
+# Set to true to turn on, usefull for testing servers.
+USE_WWW_AUTH_STRICT_PARSING = 0
+
+# In regex below:
+# [^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+ matches a "token" as defined by HTTP
+# "(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?" matches a "quoted-string" as defined by HTTP, when LWS have already been replaced by a single space
+# Actually, as an auth-param value can be either a token or a quoted-string, they are combined in a single pattern which matches both:
+# \"?((?<=\")(?:[^\0-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x08\x0A-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?
+WWW_AUTH_STRICT = re.compile(r"^(?:\s*(?:,\s*)?([^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+)\s*=\s*\"?((?<=\")(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?)(.*)$")
+WWW_AUTH_RELAXED = re.compile(r"^(?:\s*(?:,\s*)?([^ \t\r\n=]+)\s*=\s*\"?((?<=\")(?:[^\\\"]|\\.)*?(?=\")|(?<!\")[^ \t\r\n,]+(?!\"))\"?)(.*)$")
+UNQUOTE_PAIRS = re.compile(r'\\(.)')
+def _parse_www_authenticate(headers, headername='www-authenticate'):
+ """Returns a dictionary of dictionaries, one dict
+ per auth_scheme."""
+ retval = {}
+ if headers.has_key(headername):
+ authenticate = headers[headername].strip()
+ www_auth = USE_WWW_AUTH_STRICT_PARSING and WWW_AUTH_STRICT or WWW_AUTH_RELAXED
+ while authenticate:
+ # Break off the scheme at the beginning of the line
+ if headername == 'authentication-info':
+ (auth_scheme, the_rest) = ('digest', authenticate)
+ else:
+ (auth_scheme, the_rest) = authenticate.split(" ", 1)
+ # Now loop over all the key value pairs that come after the scheme,
+ # being careful not to roll into the next scheme
+ match = www_auth.search(the_rest)
+ auth_params = {}
+ while match:
+ if match and len(match.groups()) == 3:
+ (key, value, the_rest) = match.groups()
+ auth_params[key.lower()] = UNQUOTE_PAIRS.sub(r'\1', value) # '\\'.join([x.replace('\\', '') for x in value.split('\\\\')])
+ match = www_auth.search(the_rest)
+ retval[auth_scheme.lower()] = auth_params
+ authenticate = the_rest.strip()
+ return retval
+
+
+def _entry_disposition(response_headers, request_headers):
+ """Determine freshness from the Date, Expires and Cache-Control headers.
+
+ We don't handle the following:
+
+ 1. Cache-Control: max-stale
+ 2. Age: headers are not used in the calculations.
+
+ Not that this algorithm is simpler than you might think
+ because we are operating as a private (non-shared) cache.
+ This lets us ignore 's-maxage'. We can also ignore
+ 'proxy-invalidate' since we aren't a proxy.
+ We will never return a stale document as
+ fresh as a design decision, and thus the non-implementation
+ of 'max-stale'. This also lets us safely ignore 'must-revalidate'
+ since we operate as if every server has sent 'must-revalidate'.
+ Since we are private we get to ignore both 'public' and
+ 'private' parameters. We also ignore 'no-transform' since
+ we don't do any transformations.
+ The 'no-store' parameter is handled at a higher level.
+ So the only Cache-Control parameters we look at are:
+
+ no-cache
+ only-if-cached
+ max-age
+ min-fresh
+ """
+
+ retval = "STALE"
+ cc = _parse_cache_control(request_headers)
+ cc_response = _parse_cache_control(response_headers)
+
+ if request_headers.has_key('pragma') and request_headers['pragma'].lower().find('no-cache') != -1:
+ retval = "TRANSPARENT"
+ if 'cache-control' not in request_headers:
+ request_headers['cache-control'] = 'no-cache'
+ elif cc.has_key('no-cache'):
+ retval = "TRANSPARENT"
+ elif cc_response.has_key('no-cache'):
+ retval = "STALE"
+ elif cc.has_key('only-if-cached'):
+ retval = "FRESH"
+ elif response_headers.has_key('date'):
+ date = calendar.timegm(email.Utils.parsedate_tz(response_headers['date']))
+ now = time.time()
+ current_age = max(0, now - date)
+ if cc_response.has_key('max-age'):
+ try:
+ freshness_lifetime = int(cc_response['max-age'])
+ except ValueError:
+ freshness_lifetime = 0
+ elif response_headers.has_key('expires'):
+ expires = email.Utils.parsedate_tz(response_headers['expires'])
+ if None == expires:
+ freshness_lifetime = 0
+ else:
+ freshness_lifetime = max(0, calendar.timegm(expires) - date)
+ else:
+ freshness_lifetime = 0
+ if cc.has_key('max-age'):
+ try:
+ freshness_lifetime = int(cc['max-age'])
+ except ValueError:
+ freshness_lifetime = 0
+ if cc.has_key('min-fresh'):
+ try:
+ min_fresh = int(cc['min-fresh'])
+ except ValueError:
+ min_fresh = 0
+ current_age += min_fresh
+ if freshness_lifetime > current_age:
+ retval = "FRESH"
+ return retval
+
+def _decompressContent(response, new_content):
+ content = new_content
+ try:
+ encoding = response.get('content-encoding', None)
+ if encoding in ['gzip', 'deflate']:
+ if encoding == 'gzip':
+ content = gzip.GzipFile(fileobj=StringIO.StringIO(new_content)).read()
+ if encoding == 'deflate':
+ content = zlib.decompress(content)
+ response['content-length'] = str(len(content))
+ del response['content-encoding']
+ except IOError:
+ content = ""
+ raise FailedToDecompressContent(_("Content purported to be compressed with %s but failed to decompress.") % response.get('content-encoding'), response, content)
+ return content
+
+def _updateCache(request_headers, response_headers, content, cache, cachekey):
+ if cachekey:
+ cc = _parse_cache_control(request_headers)
+ cc_response = _parse_cache_control(response_headers)
+ if cc.has_key('no-store') or cc_response.has_key('no-store'):
+ cache.delete(cachekey)
+ else:
+ info = email.Message.Message()
+ for key, value in response_headers.iteritems():
+ if key not in ['status','content-encoding','transfer-encoding']:
+ info[key] = value
+
+ status = response_headers.status
+ if status == 304:
+ status = 200
+
+ status_header = 'status: %d\r\n' % response_headers.status
+
+ header_str = info.as_string()
+
+ header_str = re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", header_str)
+ text = "".join([status_header, header_str, content])
+
+ cache.set(cachekey, text)
+
+def _cnonce():
+ dig = md5.new("%s:%s" % (time.ctime(), ["0123456789"[random.randrange(0, 9)] for i in range(20)])).hexdigest()
+ return dig[:16]
+
+def _wsse_username_token(cnonce, iso_now, password):
+ return base64.encodestring(sha.new("%s%s%s" % (cnonce, iso_now, password)).digest()).strip()
+
+
+# For credentials we need two things, first
+# a pool of credential to try (not necesarily tied to BAsic, Digest, etc.)
+# Then we also need a list of URIs that have already demanded authentication
+# That list is tricky since sub-URIs can take the same auth, or the
+# auth scheme may change as you descend the tree.
+# So we also need each Auth instance to be able to tell us
+# how close to the 'top' it is.
+
+class Authentication(object):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ self.path = path
+ self.host = host
+ self.credentials = credentials
+ self.http = http
+
+ def depth(self, request_uri):
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ return request_uri[len(self.path):].count("/")
+
+ def inscope(self, host, request_uri):
+ # XXX Should we normalize the request_uri?
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ return (host == self.host) and path.startswith(self.path)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header. Over-rise this in sub-classes."""
+ pass
+
+ def response(self, response, content):
+ """Gives us a chance to update with new nonces
+ or such returned from the last authorized response.
+ Over-rise this in sub-classes if necessary.
+
+ Return TRUE is the request is to be retried, for
+ example Digest may return stale=true.
+ """
+ return False
+
+
+
+class BasicAuthentication(Authentication):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['authorization'] = 'Basic ' + base64.encodestring("%s:%s" % self.credentials).strip()
+
+
+class DigestAuthentication(Authentication):
+ """Only do qop='auth' and MD5, since that
+ is all Apache currently implements"""
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ self.challenge = challenge['digest']
+ qop = self.challenge.get('qop')
+ self.challenge['qop'] = ('auth' in [x.strip() for x in qop.split()]) and 'auth' or None
+ if self.challenge['qop'] is None:
+ raise UnimplementedDigestAuthOptionError( _("Unsupported value for qop: %s." % qop))
+ self.challenge['algorithm'] = self.challenge.get('algorithm', 'MD5')
+ if self.challenge['algorithm'] != 'MD5':
+ raise UnimplementedDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm']))
+ self.A1 = "".join([self.credentials[0], ":", self.challenge['realm'], ":", self.credentials[1]])
+ self.challenge['nc'] = 1
+
+ def request(self, method, request_uri, headers, content, cnonce = None):
+ """Modify the request headers"""
+ H = lambda x: md5.new(x).hexdigest()
+ KD = lambda s, d: H("%s:%s" % (s, d))
+ A2 = "".join([method, ":", request_uri])
+ self.challenge['cnonce'] = cnonce or _cnonce()
+ request_digest = '"%s"' % KD(H(self.A1), "%s:%s:%s:%s:%s" % (self.challenge['nonce'],
+ '%08x' % self.challenge['nc'],
+ self.challenge['cnonce'],
+ self.challenge['qop'], H(A2)
+ ))
+ headers['Authorization'] = 'Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm=%s, response=%s, qop=%s, nc=%08x, cnonce="%s"' % (
+ self.credentials[0],
+ self.challenge['realm'],
+ self.challenge['nonce'],
+ request_uri,
+ self.challenge['algorithm'],
+ request_digest,
+ self.challenge['qop'],
+ self.challenge['nc'],
+ self.challenge['cnonce'],
+ )
+ self.challenge['nc'] += 1
+
+ def response(self, response, content):
+ if not response.has_key('authentication-info'):
+ challenge = _parse_www_authenticate(response, 'www-authenticate').get('digest', {})
+ if 'true' == challenge.get('stale'):
+ self.challenge['nonce'] = challenge['nonce']
+ self.challenge['nc'] = 1
+ return True
+ else:
+ updated_challenge = _parse_www_authenticate(response, 'authentication-info').get('digest', {})
+
+ if updated_challenge.has_key('nextnonce'):
+ self.challenge['nonce'] = updated_challenge['nextnonce']
+ self.challenge['nc'] = 1
+ return False
+
+
+class HmacDigestAuthentication(Authentication):
+ """Adapted from Robert Sayre's code and DigestAuthentication above."""
+ __author__ = "Thomas Broyer (t.broyer@ltgt.net)"
+
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ self.challenge = challenge['hmacdigest']
+ # TODO: self.challenge['domain']
+ self.challenge['reason'] = self.challenge.get('reason', 'unauthorized')
+ if self.challenge['reason'] not in ['unauthorized', 'integrity']:
+ self.challenge['reason'] = 'unauthorized'
+ self.challenge['salt'] = self.challenge.get('salt', '')
+ if not self.challenge.get('snonce'):
+ raise UnimplementedHmacDigestAuthOptionError( _("The challenge doesn't contain a server nonce, or this one is empty."))
+ self.challenge['algorithm'] = self.challenge.get('algorithm', 'HMAC-SHA-1')
+ if self.challenge['algorithm'] not in ['HMAC-SHA-1', 'HMAC-MD5']:
+ raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm']))
+ self.challenge['pw-algorithm'] = self.challenge.get('pw-algorithm', 'SHA-1')
+ if self.challenge['pw-algorithm'] not in ['SHA-1', 'MD5']:
+ raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for pw-algorithm: %s." % self.challenge['pw-algorithm']))
+ if self.challenge['algorithm'] == 'HMAC-MD5':
+ self.hashmod = md5
+ else:
+ self.hashmod = sha
+ if self.challenge['pw-algorithm'] == 'MD5':
+ self.pwhashmod = md5
+ else:
+ self.pwhashmod = sha
+ self.key = "".join([self.credentials[0], ":",
+ self.pwhashmod.new("".join([self.credentials[1], self.challenge['salt']])).hexdigest().lower(),
+ ":", self.challenge['realm']
+ ])
+ self.key = self.pwhashmod.new(self.key).hexdigest().lower()
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers"""
+ keys = _get_end2end_headers(headers)
+ keylist = "".join(["%s " % k for k in keys])
+ headers_val = "".join([headers[k] for k in keys])
+ created = time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
+ cnonce = _cnonce()
+ request_digest = "%s:%s:%s:%s:%s" % (method, request_uri, cnonce, self.challenge['snonce'], headers_val)
+ request_digest = hmac.new(self.key, request_digest, self.hashmod).hexdigest().lower()
+ headers['Authorization'] = 'HMACDigest username="%s", realm="%s", snonce="%s", cnonce="%s", uri="%s", created="%s", response="%s", headers="%s"' % (
+ self.credentials[0],
+ self.challenge['realm'],
+ self.challenge['snonce'],
+ cnonce,
+ request_uri,
+ created,
+ request_digest,
+ keylist,
+ )
+
+ def response(self, response, content):
+ challenge = _parse_www_authenticate(response, 'www-authenticate').get('hmacdigest', {})
+ if challenge.get('reason') in ['integrity', 'stale']:
+ return True
+ return False
+
+
+class WsseAuthentication(Authentication):
+ """This is thinly tested and should not be relied upon.
+ At this time there isn't any third party server to test against.
+ Blogger and TypePad implemented this algorithm at one point
+ but Blogger has since switched to Basic over HTTPS and
+ TypePad has implemented it wrong, by never issuing a 401
+ challenge but instead requiring your client to telepathically know that
+ their endpoint is expecting WSSE profile="UsernameToken"."""
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['Authorization'] = 'WSSE profile="UsernameToken"'
+ iso_now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ cnonce = _cnonce()
+ password_digest = _wsse_username_token(cnonce, iso_now, self.credentials[1])
+ headers['X-WSSE'] = 'UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s"' % (
+ self.credentials[0],
+ password_digest,
+ cnonce,
+ iso_now)
+
+class GoogleLoginAuthentication(Authentication):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ from urllib import urlencode
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ service = challenge['googlelogin'].get('service', 'xapi')
+ # Bloggger actually returns the service in the challenge
+ # For the rest we guess based on the URI
+ if service == 'xapi' and request_uri.find("calendar") > 0:
+ service = "cl"
+ # No point in guessing Base or Spreadsheet
+ #elif request_uri.find("spreadsheets") > 0:
+ # service = "wise"
+
+ auth = dict(Email=credentials[0], Passwd=credentials[1], service=service, source=headers['user-agent'])
+ resp, content = self.http.request("https://www.google.com/accounts/ClientLogin", method="POST", body=urlencode(auth), headers={'Content-Type': 'application/x-www-form-urlencoded'})
+ lines = content.split('\n')
+ d = dict([tuple(line.split("=", 1)) for line in lines if line])
+ if resp.status == 403:
+ self.Auth = ""
+ else:
+ self.Auth = d['Auth']
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['authorization'] = 'GoogleLogin Auth=' + self.Auth
+
+
+AUTH_SCHEME_CLASSES = {
+ "basic": BasicAuthentication,
+ "wsse": WsseAuthentication,
+ "digest": DigestAuthentication,
+ "hmacdigest": HmacDigestAuthentication,
+ "googlelogin": GoogleLoginAuthentication
+}
+
+AUTH_SCHEME_ORDER = ["hmacdigest", "googlelogin", "digest", "wsse", "basic"]
+
+def _md5(s):
+ return
+
+class FileCache(object):
+ """Uses a local directory as a store for cached files.
+ Not really safe to use if multiple threads or processes are going to
+ be running on the same cache.
+ """
+ def __init__(self, cache, safe=safename): # use safe=lambda x: md5.new(x).hexdigest() for the old behavior
+ self.cache = cache
+ self.safe = safe
+ if not os.path.exists(cache):
+ os.makedirs(self.cache)
+
+ def get(self, key):
+ retval = None
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ try:
+ f = file(cacheFullPath, "r")
+ retval = f.read()
+ f.close()
+ except IOError:
+ pass
+ return retval
+
+ def set(self, key, value):
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ f = file(cacheFullPath, "w")
+ f.write(value)
+ f.close()
+
+ def delete(self, key):
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ if os.path.exists(cacheFullPath):
+ os.remove(cacheFullPath)
+
+class Credentials(object):
+ def __init__(self):
+ self.credentials = []
+
+ def add(self, name, password, domain=""):
+ self.credentials.append((domain.lower(), name, password))
+
+ def clear(self):
+ self.credentials = []
+
+ def iter(self, domain):
+ for (cdomain, name, password) in self.credentials:
+ if cdomain == "" or domain == cdomain:
+ yield (name, password)
+
+class KeyCerts(Credentials):
+ """Identical to Credentials except that
+ name/password are mapped to key/cert."""
+ pass
+
+
+class ProxyInfo(object):
+ """Collect information required to use a proxy."""
+ def __init__(self, proxy_type, proxy_host, proxy_port, proxy_rdns=None, proxy_user=None, proxy_pass=None):
+ """The parameter proxy_type must be set to one of socks.PROXY_TYPE_XXX
+ constants. For example:
+
+p = ProxyInfo(proxy_type=socks.PROXY_TYPE_HTTP, proxy_host='localhost', proxy_port=8000)
+ """
+ self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, self.proxy_user, self.proxy_pass = proxy_type, proxy_host, proxy_port, proxy_rdns, proxy_user, proxy_pass
+
+ def astuple(self):
+ return (self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns,
+ self.proxy_user, self.proxy_pass)
+
+ def isgood(self):
+ return socks and (self.proxy_host != None) and (self.proxy_port != None)
+
+
+class HTTPConnectionWithTimeout(httplib.HTTPConnection):
+ """HTTPConnection subclass that supports timeouts"""
+
+ def __init__(self, host, port=None, strict=None, timeout=None, proxy_info=None):
+ httplib.HTTPConnection.__init__(self, host, port, strict)
+ self.timeout = timeout
+ self.proxy_info = proxy_info
+
+ def connect(self):
+ """Connect to the host and port specified in __init__."""
+ # Mostly verbatim from httplib.py.
+ msg = "getaddrinfo returns an empty list"
+ for res in socket.getaddrinfo(self.host, self.port, 0,
+ socket.SOCK_STREAM):
+ af, socktype, proto, canonname, sa = res
+ try:
+ if self.proxy_info and self.proxy_info.isgood():
+ self.sock = socks.socksocket(af, socktype, proto)
+ self.sock.setproxy(*self.proxy_info.astuple())
+ else:
+ self.sock = socket.socket(af, socktype, proto)
+ # Different from httplib: support timeouts.
+ if self.timeout is not None:
+ self.sock.settimeout(self.timeout)
+ # End of difference from httplib.
+ if self.debuglevel > 0:
+ print "connect: (%s, %s)" % (self.host, self.port)
+ self.sock.connect(sa)
+ except socket.error, msg:
+ if self.debuglevel > 0:
+ print 'connect fail:', (self.host, self.port)
+ if self.sock:
+ self.sock.close()
+ self.sock = None
+ continue
+ break
+ if not self.sock:
+ raise socket.error, msg
+
+class HTTPSConnectionWithTimeout(httplib.HTTPSConnection):
+ "This class allows communication via SSL."
+
+ def __init__(self, host, port=None, key_file=None, cert_file=None,
+ strict=None, timeout=None, proxy_info=None):
+ self.timeout = timeout
+ self.proxy_info = proxy_info
+ httplib.HTTPSConnection.__init__(self, host, port=port, key_file=key_file,
+ cert_file=cert_file, strict=strict)
+
+ def connect(self):
+ "Connect to a host on a given (SSL) port."
+
+ if self.proxy_info and self.proxy_info.isgood():
+ self.sock.setproxy(*self.proxy_info.astuple())
+ sock.setproxy(*self.proxy_info.astuple())
+ else:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ if self.timeout is not None:
+ sock.settimeout(self.timeout)
+ sock.connect((self.host, self.port))
+ ssl = socket.ssl(sock, self.key_file, self.cert_file)
+ self.sock = httplib.FakeSocket(sock, ssl)
+
+
+
+class Http(object):
+ """An HTTP client that handles:
+- all methods
+- caching
+- ETags
+- compression,
+- HTTPS
+- Basic
+- Digest
+- WSSE
+
+and more.
+ """
+ def __init__(self, cache=None, timeout=None, proxy_info=None):
+ """The value of proxy_info is a ProxyInfo instance.
+
+If 'cache' is a string then it is used as a directory name
+for a disk cache. Otherwise it must be an object that supports
+the same interface as FileCache."""
+ self.proxy_info = proxy_info
+ # Map domain name to an httplib connection
+ self.connections = {}
+ # The location of the cache, for now a directory
+ # where cached responses are held.
+ if cache and isinstance(cache, str):
+ self.cache = FileCache(cache)
+ else:
+ self.cache = cache
+
+ # Name/password
+ self.credentials = Credentials()
+
+ # Key/cert
+ self.certificates = KeyCerts()
+
+ # authorization objects
+ self.authorizations = []
+
+ # If set to False then no redirects are followed, even safe ones.
+ self.follow_redirects = True
+
+ # If 'follow_redirects' is True, and this is set to True then
+ # all redirecs are followed, including unsafe ones.
+ self.follow_all_redirects = False
+
+ self.ignore_etag = False
+
+ self.force_exception_to_status_code = False
+
+ self.timeout = timeout
+
+ def _auth_from_challenge(self, host, request_uri, headers, response, content):
+ """A generator that creates Authorization objects
+ that can be applied to requests.
+ """
+ challenges = _parse_www_authenticate(response, 'www-authenticate')
+ for cred in self.credentials.iter(host):
+ for scheme in AUTH_SCHEME_ORDER:
+ if challenges.has_key(scheme):
+ yield AUTH_SCHEME_CLASSES[scheme](cred, host, request_uri, headers, response, content, self)
+
+ def add_credentials(self, name, password, domain=""):
+ """Add a name and password that will be used
+ any time a request requires authentication."""
+ self.credentials.add(name, password, domain)
+
+ def add_certificate(self, key, cert, domain):
+ """Add a key and cert that will be used
+ any time a request requires authentication."""
+ self.certificates.add(key, cert, domain)
+
+ def clear_credentials(self):
+ """Remove all the names and passwords
+ that are used for authentication"""
+ self.credentials.clear()
+ self.authorizations = []
+
+ def _conn_request(self, conn, request_uri, method, body, headers):
+ for i in range(2):
+ try:
+ conn.request(method, request_uri, body, headers)
+ response = conn.getresponse()
+ except socket.gaierror:
+ conn.close()
+ raise ServerNotFoundError("Unable to find the server at %s" % conn.host)
+ except httplib.HTTPException, e:
+ if i == 0:
+ conn.close()
+ conn.connect()
+ continue
+ else:
+ raise
+ else:
+ content = response.read()
+ response = Response(response)
+ if method != "HEAD":
+ content = _decompressContent(response, content)
+
+ break;
+ return (response, content)
+
+
+ def _request(self, conn, host, absolute_uri, request_uri, method, body, headers, redirections, cachekey):
+ """Do the actual request using the connection object
+ and also follow one level of redirects if necessary"""
+
+ auths = [(auth.depth(request_uri), auth) for auth in self.authorizations if auth.inscope(host, request_uri)]
+ auth = auths and sorted(auths)[0][1] or None
+ if auth:
+ auth.request(method, request_uri, headers, body)
+
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers)
+
+ if auth:
+ if auth.response(response, body):
+ auth.request(method, request_uri, headers, body)
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers )
+ response._stale_digest = 1
+
+ if response.status == 401:
+ for authorization in self._auth_from_challenge(host, request_uri, headers, response, content):
+ authorization.request(method, request_uri, headers, body)
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers, )
+ if response.status != 401:
+ self.authorizations.append(authorization)
+ authorization.response(response, body)
+ break
+
+ if (self.follow_all_redirects or (method in ["GET", "HEAD"]) or response.status == 303):
+ if self.follow_redirects and response.status in [300, 301, 302, 303, 307]:
+ # Pick out the location header and basically start from the beginning
+ # remembering first to strip the ETag header and decrement our 'depth'
+ if redirections:
+ if not response.has_key('location') and response.status != 300:
+ raise RedirectMissingLocation( _("Redirected but the response is missing a Location: header."), response, content)
+ # Fix-up relative redirects (which violate an RFC 2616 MUST)
+ if response.has_key('location'):
+ location = response['location']
+ (scheme, authority, path, query, fragment) = parse_uri(location)
+ if authority == None:
+ response['location'] = urlparse.urljoin(absolute_uri, location)
+ if response.status == 301 and method in ["GET", "HEAD"]:
+ response['-x-permanent-redirect-url'] = response['location']
+ if not response.has_key('content-location'):
+ response['content-location'] = absolute_uri
+ _updateCache(headers, response, content, self.cache, cachekey)
+ if headers.has_key('if-none-match'):
+ del headers['if-none-match']
+ if headers.has_key('if-modified-since'):
+ del headers['if-modified-since']
+ if response.has_key('location'):
+ location = response['location']
+ old_response = copy.deepcopy(response)
+ if not old_response.has_key('content-location'):
+ old_response['content-location'] = absolute_uri
+ redirect_method = ((response.status == 303) and (method not in ["GET", "HEAD"])) and "GET" or method
+ (response, content) = self.request(location, redirect_method, body=body, headers = headers, redirections = redirections - 1)
+ response.previous = old_response
+ else:
+ raise RedirectLimit( _("Redirected more times than rediection_limit allows."), response, content)
+ elif response.status in [200, 203] and method == "GET":
+ # Don't cache 206's since we aren't going to handle byte range requests
+ if not response.has_key('content-location'):
+ response['content-location'] = absolute_uri
+ _updateCache(headers, response, content, self.cache, cachekey)
+
+ return (response, content)
+
+
+# Need to catch and rebrand some exceptions
+# Then need to optionally turn all exceptions into status codes
+# including all socket.* and httplib.* exceptions.
+
+
+ def request(self, uri, method="GET", body=None, headers=None, redirections=DEFAULT_MAX_REDIRECTS, connection_type=None):
+ """ Performs a single HTTP request.
+The 'uri' is the URI of the HTTP resource and can begin
+with either 'http' or 'https'. The value of 'uri' must be an absolute URI.
+
+The 'method' is the HTTP method to perform, such as GET, POST, DELETE, etc.
+There is no restriction on the methods allowed.
+
+The 'body' is the entity body to be sent with the request. It is a string
+object.
+
+Any extra headers that are to be sent with the request should be provided in the
+'headers' dictionary.
+
+The maximum number of redirect to follow before raising an
+exception is 'redirections. The default is 5.
+
+The return value is a tuple of (response, content), the first
+being and instance of the 'Response' class, the second being
+a string that contains the response entity body.
+ """
+ try:
+ if headers is None:
+ headers = {}
+ else:
+ headers = _normalize_headers(headers)
+
+ if not headers.has_key('user-agent'):
+ headers['user-agent'] = "Python-httplib2/%s" % __version__
+
+ uri = iri2uri(uri)
+
+ (scheme, authority, request_uri, defrag_uri) = urlnorm(uri)
+
+ conn_key = scheme+":"+authority
+ if conn_key in self.connections:
+ conn = self.connections[conn_key]
+ else:
+ if not connection_type:
+ connection_type = (scheme == 'https') and HTTPSConnectionWithTimeout or HTTPConnectionWithTimeout
+ certs = list(self.certificates.iter(authority))
+ if scheme == 'https' and certs:
+ conn = self.connections[conn_key] = connection_type(authority, key_file=certs[0][0],
+ cert_file=certs[0][1], timeout=self.timeout, proxy_info=self.proxy_info)
+ else:
+ conn = self.connections[conn_key] = connection_type(authority, timeout=self.timeout, proxy_info=self.proxy_info)
+ conn.set_debuglevel(debuglevel)
+
+ if method in ["GET", "HEAD"] and 'range' not in headers:
+ headers['accept-encoding'] = 'compress, gzip'
+
+ info = email.Message.Message()
+ cached_value = None
+ if self.cache:
+ cachekey = defrag_uri
+ cached_value = self.cache.get(cachekey)
+ if cached_value:
+ info = email.message_from_string(cached_value)
+ try:
+ content = cached_value.split('\r\n\r\n', 1)[1]
+ except IndexError:
+ self.cache.delete(cachekey)
+ cachekey = None
+ cached_value = None
+ else:
+ cachekey = None
+
+ if method in ["PUT"] and self.cache and info.has_key('etag') and not self.ignore_etag and 'if-match' not in headers:
+ # http://www.w3.org/1999/04/Editing/
+ headers['if-match'] = info['etag']
+
+ if method not in ["GET", "HEAD"] and self.cache and cachekey:
+ # RFC 2616 Section 13.10
+ self.cache.delete(cachekey)
+
+ if cached_value and method in ["GET", "HEAD"] and self.cache and 'range' not in headers:
+ if info.has_key('-x-permanent-redirect-url'):
+ # Should cached permanent redirects be counted in our redirection count? For now, yes.
+ (response, new_content) = self.request(info['-x-permanent-redirect-url'], "GET", headers = headers, redirections = redirections - 1)
+ response.previous = Response(info)
+ response.previous.fromcache = True
+ else:
+ # Determine our course of action:
+ # Is the cached entry fresh or stale?
+ # Has the client requested a non-cached response?
+ #
+ # There seems to be three possible answers:
+ # 1. [FRESH] Return the cache entry w/o doing a GET
+ # 2. [STALE] Do the GET (but add in cache validators if available)
+ # 3. [TRANSPARENT] Do a GET w/o any cache validators (Cache-Control: no-cache) on the request
+ entry_disposition = _entry_disposition(info, headers)
+
+ if entry_disposition == "FRESH":
+ if not cached_value:
+ info['status'] = '504'
+ content = ""
+ response = Response(info)
+ if cached_value:
+ response.fromcache = True
+ return (response, content)
+
+ if entry_disposition == "STALE":
+ if info.has_key('etag') and not self.ignore_etag and not 'if-none-match' in headers:
+ headers['if-none-match'] = info['etag']
+ if info.has_key('last-modified') and not 'last-modified' in headers:
+ headers['if-modified-since'] = info['last-modified']
+ elif entry_disposition == "TRANSPARENT":
+ pass
+
+ (response, new_content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
+
+ if response.status == 304 and method == "GET":
+ # Rewrite the cache entry with the new end-to-end headers
+ # Take all headers that are in response
+ # and overwrite their values in info.
+ # unless they are hop-by-hop, or are listed in the connection header.
+
+ for key in _get_end2end_headers(response):
+ info[key] = response[key]
+ merged_response = Response(info)
+ if hasattr(response, "_stale_digest"):
+ merged_response._stale_digest = response._stale_digest
+ _updateCache(headers, merged_response, content, self.cache, cachekey)
+ response = merged_response
+ response.status = 200
+ response.fromcache = True
+
+ elif response.status == 200:
+ content = new_content
+ else:
+ self.cache.delete(cachekey)
+ content = new_content
+ else:
+ (response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
+ except Exception, e:
+ if self.force_exception_to_status_code:
+ if isinstance(e, HttpLib2ErrorWithResponse):
+ response = e.response
+ content = e.content
+ response.status = 500
+ response.reason = str(e)
+ elif isinstance(e, socket.timeout):
+ content = "Request Timeout"
+ response = Response( {
+ "content-type": "text/plain",
+ "status": "408",
+ "content-length": len(content)
+ })
+ response.reason = "Request Timeout"
+ else:
+ content = str(e)
+ response = Response( {
+ "content-type": "text/plain",
+ "status": "400",
+ "content-length": len(content)
+ })
+ response.reason = "Bad Request"
+ else:
+ raise
+
+
+ return (response, content)
+
+
+
+class Response(dict):
+ """An object more like email.Message than httplib.HTTPResponse."""
+
+ """Is this response from our local cache"""
+ fromcache = False
+
+ """HTTP protocol version used by server. 10 for HTTP/1.0, 11 for HTTP/1.1. """
+ version = 11
+
+ "Status code returned by server. "
+ status = 200
+
+ """Reason phrase returned by server."""
+ reason = "Ok"
+
+ previous = None
+
+ def __init__(self, info):
+ # info is either an email.Message or
+ # an httplib.HTTPResponse object.
+ if isinstance(info, httplib.HTTPResponse):
+ for key, value in info.getheaders():
+ self[key] = value
+ self.status = info.status
+ self['status'] = str(self.status)
+ self.reason = info.reason
+ self.version = info.version
+ elif isinstance(info, email.Message.Message):
+ for key, value in info.items():
+ self[key] = value
+ self.status = int(self['status'])
+ else:
+ for key, value in info.iteritems():
+ self[key] = value
+ self.status = int(self.get('status', self.status))
+
+
+ def __getattr__(self, name):
+ if name == 'dict':
+ return self
+ else:
+ raise AttributeError, name
diff --git a/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base b/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base
new file mode 100644
index 0000000..f092204
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base
Binary files differ
diff --git a/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base
new file mode 100644
index 0000000..70667ed
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base
@@ -0,0 +1,110 @@
+"""
+iri2uri
+
+Converts an IRI to a URI.
+
+"""
+__author__ = "Joe Gregorio (joe@bitworking.org)"
+__copyright__ = "Copyright 2006, Joe Gregorio"
+__contributors__ = []
+__version__ = "1.0.0"
+__license__ = "MIT"
+__history__ = """
+"""
+
+import urlparse
+
+
+# Convert an IRI to a URI following the rules in RFC 3987
+#
+# The characters we need to enocde and escape are defined in the spec:
+#
+# iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD
+# ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF
+# / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD
+# / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD
+# / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD
+# / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD
+# / %xD0000-DFFFD / %xE1000-EFFFD
+
+escape_range = [
+ (0xA0, 0xD7FF ),
+ (0xE000, 0xF8FF ),
+ (0xF900, 0xFDCF ),
+ (0xFDF0, 0xFFEF),
+ (0x10000, 0x1FFFD ),
+ (0x20000, 0x2FFFD ),
+ (0x30000, 0x3FFFD),
+ (0x40000, 0x4FFFD ),
+ (0x50000, 0x5FFFD ),
+ (0x60000, 0x6FFFD),
+ (0x70000, 0x7FFFD ),
+ (0x80000, 0x8FFFD ),
+ (0x90000, 0x9FFFD),
+ (0xA0000, 0xAFFFD ),
+ (0xB0000, 0xBFFFD ),
+ (0xC0000, 0xCFFFD),
+ (0xD0000, 0xDFFFD ),
+ (0xE1000, 0xEFFFD),
+ (0xF0000, 0xFFFFD ),
+ (0x100000, 0x10FFFD)
+]
+
+def encode(c):
+ retval = c
+ i = ord(c)
+ for low, high in escape_range:
+ if i < low:
+ break
+ if i >= low and i <= high:
+ retval = "".join(["%%%2X" % ord(o) for o in c.encode('utf-8')])
+ break
+ return retval
+
+
+def iri2uri(uri):
+ """Convert an IRI to a URI. Note that IRIs must be
+ passed in a unicode strings. That is, do not utf-8 encode
+ the IRI before passing it into the function."""
+ if isinstance(uri ,unicode):
+ (scheme, authority, path, query, fragment) = urlparse.urlsplit(uri)
+ authority = authority.encode('idna')
+ # For each character in 'ucschar' or 'iprivate'
+ # 1. encode as utf-8
+ # 2. then %-encode each octet of that utf-8
+ uri = urlparse.urlunsplit((scheme, authority, path, query, fragment))
+ uri = "".join([encode(c) for c in uri])
+ return uri
+
+if __name__ == "__main__":
+ import unittest
+
+ class Test(unittest.TestCase):
+
+ def test_uris(self):
+ """Test that URIs are invariant under the transformation."""
+ invariant = [
+ u"ftp://ftp.is.co.za/rfc/rfc1808.txt",
+ u"http://www.ietf.org/rfc/rfc2396.txt",
+ u"ldap://[2001:db8::7]/c=GB?objectClass?one",
+ u"mailto:John.Doe@example.com",
+ u"news:comp.infosystems.www.servers.unix",
+ u"tel:+1-816-555-1212",
+ u"telnet://192.0.2.16:80/",
+ u"urn:oasis:names:specification:docbook:dtd:xml:4.1.2" ]
+ for uri in invariant:
+ self.assertEqual(uri, iri2uri(uri))
+
+ def test_iri(self):
+ """ Test that the right type of escaping is done for each part of the URI."""
+ self.assertEqual("http://xn--o3h.com/%E2%98%84", iri2uri(u"http://\N{COMET}.com/\N{COMET}"))
+ self.assertEqual("http://bitworking.org/?fred=%E2%98%84", iri2uri(u"http://bitworking.org/?fred=\N{COMET}"))
+ self.assertEqual("http://bitworking.org/#%E2%98%84", iri2uri(u"http://bitworking.org/#\N{COMET}"))
+ self.assertEqual("#%E2%98%84", iri2uri(u"#\N{COMET}"))
+ self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}"))
+ self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}")))
+ self.assertNotEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}".encode('utf-8')))
+
+ unittest.main()
+
+
diff --git a/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base
new file mode 100644
index 0000000..e16a3db
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base
Binary files differ
diff --git a/tutorius/apilib/httplib2/__init__.py b/tutorius/apilib/httplib2/__init__.py
new file mode 100644
index 0000000..982bf8a
--- /dev/null
+++ b/tutorius/apilib/httplib2/__init__.py
@@ -0,0 +1,1123 @@
+from __future__ import generators
+"""
+httplib2
+
+A caching http interface that supports ETags and gzip
+to conserve bandwidth.
+
+Requires Python 2.3 or later
+
+Changelog:
+2007-08-18, Rick: Modified so it's able to use a socks proxy if needed.
+
+"""
+
+__author__ = "Joe Gregorio (joe@bitworking.org)"
+__copyright__ = "Copyright 2006, Joe Gregorio"
+__contributors__ = ["Thomas Broyer (t.broyer@ltgt.net)",
+ "James Antill",
+ "Xavier Verges Farrero",
+ "Jonathan Feinberg",
+ "Blair Zajac",
+ "Sam Ruby",
+ "Louis Nyffenegger"]
+__license__ = "MIT"
+__version__ = "$Rev: 259 $"
+
+import re
+import sys
+import md5
+import email
+import email.Utils
+import email.Message
+import StringIO
+import gzip
+import zlib
+import httplib
+import urlparse
+import base64
+import os
+import copy
+import calendar
+import time
+import random
+import sha
+import hmac
+from gettext import gettext as _
+import socket
+
+try:
+ import socks
+except ImportError:
+ socks = None
+
+if sys.version_info >= (2,3):
+ from iri2uri import iri2uri
+else:
+ def iri2uri(uri):
+ return uri
+
+__all__ = ['Http', 'Response', 'ProxyInfo', 'HttpLib2Error',
+ 'RedirectMissingLocation', 'RedirectLimit', 'FailedToDecompressContent',
+ 'UnimplementedDigestAuthOptionError', 'UnimplementedHmacDigestAuthOptionError',
+ 'debuglevel']
+
+
+# The httplib debug level, set to a non-zero value to get debug output
+debuglevel = 0
+
+# Python 2.3 support
+if sys.version_info < (2,4):
+ def sorted(seq):
+ seq.sort()
+ return seq
+
+# Python 2.3 support
+def HTTPResponse__getheaders(self):
+ """Return list of (header, value) tuples."""
+ if self.msg is None:
+ raise httplib.ResponseNotReady()
+ return self.msg.items()
+
+if not hasattr(httplib.HTTPResponse, 'getheaders'):
+ httplib.HTTPResponse.getheaders = HTTPResponse__getheaders
+
+# All exceptions raised here derive from HttpLib2Error
+class HttpLib2Error(Exception): pass
+
+# Some exceptions can be caught and optionally
+# be turned back into responses.
+class HttpLib2ErrorWithResponse(HttpLib2Error):
+ def __init__(self, desc, response, content):
+ self.response = response
+ self.content = content
+ HttpLib2Error.__init__(self, desc)
+
+class RedirectMissingLocation(HttpLib2ErrorWithResponse): pass
+class RedirectLimit(HttpLib2ErrorWithResponse): pass
+class FailedToDecompressContent(HttpLib2ErrorWithResponse): pass
+class UnimplementedDigestAuthOptionError(HttpLib2ErrorWithResponse): pass
+class UnimplementedHmacDigestAuthOptionError(HttpLib2ErrorWithResponse): pass
+
+class RelativeURIError(HttpLib2Error): pass
+class ServerNotFoundError(HttpLib2Error): pass
+
+# Open Items:
+# -----------
+# Proxy support
+
+# Are we removing the cached content too soon on PUT (only delete on 200 Maybe?)
+
+# Pluggable cache storage (supports storing the cache in
+# flat files by default. We need a plug-in architecture
+# that can support Berkeley DB and Squid)
+
+# == Known Issues ==
+# Does not handle a resource that uses conneg and Last-Modified but no ETag as a cache validator.
+# Does not handle Cache-Control: max-stale
+# Does not use Age: headers when calculating cache freshness.
+
+
+# The number of redirections to follow before giving up.
+# Note that only GET redirects are automatically followed.
+# Will also honor 301 requests by saving that info and never
+# requesting that URI again.
+DEFAULT_MAX_REDIRECTS = 5
+
+# Which headers are hop-by-hop headers by default
+HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade']
+
+def _get_end2end_headers(response):
+ hopbyhop = list(HOP_BY_HOP)
+ hopbyhop.extend([x.strip() for x in response.get('connection', '').split(',')])
+ return [header for header in response.keys() if header not in hopbyhop]
+
+URI = re.compile(r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?")
+
+def parse_uri(uri):
+ """Parses a URI using the regex given in Appendix B of RFC 3986.
+
+ (scheme, authority, path, query, fragment) = parse_uri(uri)
+ """
+ groups = URI.match(uri).groups()
+ return (groups[1], groups[3], groups[4], groups[6], groups[8])
+
+def urlnorm(uri):
+ (scheme, authority, path, query, fragment) = parse_uri(uri)
+ if not scheme or not authority:
+ raise RelativeURIError("Only absolute URIs are allowed. uri = %s" % uri)
+ authority = authority.lower()
+ scheme = scheme.lower()
+ if not path:
+ path = "/"
+ # Could do syntax based normalization of the URI before
+ # computing the digest. See Section 6.2.2 of Std 66.
+ request_uri = query and "?".join([path, query]) or path
+ scheme = scheme.lower()
+ defrag_uri = scheme + "://" + authority + request_uri
+ return scheme, authority, request_uri, defrag_uri
+
+
+# Cache filename construction (original borrowed from Venus http://intertwingly.net/code/venus/)
+re_url_scheme = re.compile(r'^\w+://')
+re_slash = re.compile(r'[?/:|]+')
+
+def safename(filename):
+ """Return a filename suitable for the cache.
+
+ Strips dangerous and common characters to create a filename we
+ can use to store the cache in.
+ """
+
+ try:
+ if re_url_scheme.match(filename):
+ if isinstance(filename,str):
+ filename = filename.decode('utf-8')
+ filename = filename.encode('idna')
+ else:
+ filename = filename.encode('idna')
+ except UnicodeError:
+ pass
+ if isinstance(filename,unicode):
+ filename=filename.encode('utf-8')
+ filemd5 = md5.new(filename).hexdigest()
+ filename = re_url_scheme.sub("", filename)
+ filename = re_slash.sub(",", filename)
+
+ # limit length of filename
+ if len(filename)>200:
+ filename=filename[:200]
+ return ",".join((filename, filemd5))
+
+NORMALIZE_SPACE = re.compile(r'(?:\r\n)?[ \t]+')
+def _normalize_headers(headers):
+ return dict([ (key.lower(), NORMALIZE_SPACE.sub(value, ' ').strip()) for (key, value) in headers.iteritems()])
+
+def _parse_cache_control(headers):
+ retval = {}
+ if headers.has_key('cache-control'):
+ parts = headers['cache-control'].split(',')
+ parts_with_args = [tuple([x.strip() for x in part.split("=")]) for part in parts if -1 != part.find("=")]
+ parts_wo_args = [(name.strip(), 1) for name in parts if -1 == name.find("=")]
+ retval = dict(parts_with_args + parts_wo_args)
+ return retval
+
+# Whether to use a strict mode to parse WWW-Authenticate headers
+# Might lead to bad results in case of ill-formed header value,
+# so disabled by default, falling back to relaxed parsing.
+# Set to true to turn on, usefull for testing servers.
+USE_WWW_AUTH_STRICT_PARSING = 0
+
+# In regex below:
+# [^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+ matches a "token" as defined by HTTP
+# "(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?" matches a "quoted-string" as defined by HTTP, when LWS have already been replaced by a single space
+# Actually, as an auth-param value can be either a token or a quoted-string, they are combined in a single pattern which matches both:
+# \"?((?<=\")(?:[^\0-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x08\x0A-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?
+WWW_AUTH_STRICT = re.compile(r"^(?:\s*(?:,\s*)?([^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+)\s*=\s*\"?((?<=\")(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?)(.*)$")
+WWW_AUTH_RELAXED = re.compile(r"^(?:\s*(?:,\s*)?([^ \t\r\n=]+)\s*=\s*\"?((?<=\")(?:[^\\\"]|\\.)*?(?=\")|(?<!\")[^ \t\r\n,]+(?!\"))\"?)(.*)$")
+UNQUOTE_PAIRS = re.compile(r'\\(.)')
+def _parse_www_authenticate(headers, headername='www-authenticate'):
+ """Returns a dictionary of dictionaries, one dict
+ per auth_scheme."""
+ retval = {}
+ if headers.has_key(headername):
+ authenticate = headers[headername].strip()
+ www_auth = USE_WWW_AUTH_STRICT_PARSING and WWW_AUTH_STRICT or WWW_AUTH_RELAXED
+ while authenticate:
+ # Break off the scheme at the beginning of the line
+ if headername == 'authentication-info':
+ (auth_scheme, the_rest) = ('digest', authenticate)
+ else:
+ (auth_scheme, the_rest) = authenticate.split(" ", 1)
+ # Now loop over all the key value pairs that come after the scheme,
+ # being careful not to roll into the next scheme
+ match = www_auth.search(the_rest)
+ auth_params = {}
+ while match:
+ if match and len(match.groups()) == 3:
+ (key, value, the_rest) = match.groups()
+ auth_params[key.lower()] = UNQUOTE_PAIRS.sub(r'\1', value) # '\\'.join([x.replace('\\', '') for x in value.split('\\\\')])
+ match = www_auth.search(the_rest)
+ retval[auth_scheme.lower()] = auth_params
+ authenticate = the_rest.strip()
+ return retval
+
+
+def _entry_disposition(response_headers, request_headers):
+ """Determine freshness from the Date, Expires and Cache-Control headers.
+
+ We don't handle the following:
+
+ 1. Cache-Control: max-stale
+ 2. Age: headers are not used in the calculations.
+
+ Not that this algorithm is simpler than you might think
+ because we are operating as a private (non-shared) cache.
+ This lets us ignore 's-maxage'. We can also ignore
+ 'proxy-invalidate' since we aren't a proxy.
+ We will never return a stale document as
+ fresh as a design decision, and thus the non-implementation
+ of 'max-stale'. This also lets us safely ignore 'must-revalidate'
+ since we operate as if every server has sent 'must-revalidate'.
+ Since we are private we get to ignore both 'public' and
+ 'private' parameters. We also ignore 'no-transform' since
+ we don't do any transformations.
+ The 'no-store' parameter is handled at a higher level.
+ So the only Cache-Control parameters we look at are:
+
+ no-cache
+ only-if-cached
+ max-age
+ min-fresh
+ """
+
+ retval = "STALE"
+ cc = _parse_cache_control(request_headers)
+ cc_response = _parse_cache_control(response_headers)
+
+ if request_headers.has_key('pragma') and request_headers['pragma'].lower().find('no-cache') != -1:
+ retval = "TRANSPARENT"
+ if 'cache-control' not in request_headers:
+ request_headers['cache-control'] = 'no-cache'
+ elif cc.has_key('no-cache'):
+ retval = "TRANSPARENT"
+ elif cc_response.has_key('no-cache'):
+ retval = "STALE"
+ elif cc.has_key('only-if-cached'):
+ retval = "FRESH"
+ elif response_headers.has_key('date'):
+ date = calendar.timegm(email.Utils.parsedate_tz(response_headers['date']))
+ now = time.time()
+ current_age = max(0, now - date)
+ if cc_response.has_key('max-age'):
+ try:
+ freshness_lifetime = int(cc_response['max-age'])
+ except ValueError:
+ freshness_lifetime = 0
+ elif response_headers.has_key('expires'):
+ expires = email.Utils.parsedate_tz(response_headers['expires'])
+ if None == expires:
+ freshness_lifetime = 0
+ else:
+ freshness_lifetime = max(0, calendar.timegm(expires) - date)
+ else:
+ freshness_lifetime = 0
+ if cc.has_key('max-age'):
+ try:
+ freshness_lifetime = int(cc['max-age'])
+ except ValueError:
+ freshness_lifetime = 0
+ if cc.has_key('min-fresh'):
+ try:
+ min_fresh = int(cc['min-fresh'])
+ except ValueError:
+ min_fresh = 0
+ current_age += min_fresh
+ if freshness_lifetime > current_age:
+ retval = "FRESH"
+ return retval
+
+def _decompressContent(response, new_content):
+ content = new_content
+ try:
+ encoding = response.get('content-encoding', None)
+ if encoding in ['gzip', 'deflate']:
+ if encoding == 'gzip':
+ content = gzip.GzipFile(fileobj=StringIO.StringIO(new_content)).read()
+ if encoding == 'deflate':
+ content = zlib.decompress(content)
+ response['content-length'] = str(len(content))
+ del response['content-encoding']
+ except IOError:
+ content = ""
+ raise FailedToDecompressContent(_("Content purported to be compressed with %s but failed to decompress.") % response.get('content-encoding'), response, content)
+ return content
+
+def _updateCache(request_headers, response_headers, content, cache, cachekey):
+ if cachekey:
+ cc = _parse_cache_control(request_headers)
+ cc_response = _parse_cache_control(response_headers)
+ if cc.has_key('no-store') or cc_response.has_key('no-store'):
+ cache.delete(cachekey)
+ else:
+ info = email.Message.Message()
+ for key, value in response_headers.iteritems():
+ if key not in ['status','content-encoding','transfer-encoding']:
+ info[key] = value
+
+ status = response_headers.status
+ if status == 304:
+ status = 200
+
+ status_header = 'status: %d\r\n' % response_headers.status
+
+ header_str = info.as_string()
+
+ header_str = re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", header_str)
+ text = "".join([status_header, header_str, content])
+
+ cache.set(cachekey, text)
+
+def _cnonce():
+ dig = md5.new("%s:%s" % (time.ctime(), ["0123456789"[random.randrange(0, 9)] for i in range(20)])).hexdigest()
+ return dig[:16]
+
+def _wsse_username_token(cnonce, iso_now, password):
+ return base64.encodestring(sha.new("%s%s%s" % (cnonce, iso_now, password)).digest()).strip()
+
+
+# For credentials we need two things, first
+# a pool of credential to try (not necesarily tied to BAsic, Digest, etc.)
+# Then we also need a list of URIs that have already demanded authentication
+# That list is tricky since sub-URIs can take the same auth, or the
+# auth scheme may change as you descend the tree.
+# So we also need each Auth instance to be able to tell us
+# how close to the 'top' it is.
+
+class Authentication(object):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ self.path = path
+ self.host = host
+ self.credentials = credentials
+ self.http = http
+
+ def depth(self, request_uri):
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ return request_uri[len(self.path):].count("/")
+
+ def inscope(self, host, request_uri):
+ # XXX Should we normalize the request_uri?
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ return (host == self.host) and path.startswith(self.path)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header. Over-rise this in sub-classes."""
+ pass
+
+ def response(self, response, content):
+ """Gives us a chance to update with new nonces
+ or such returned from the last authorized response.
+ Over-rise this in sub-classes if necessary.
+
+ Return TRUE is the request is to be retried, for
+ example Digest may return stale=true.
+ """
+ return False
+
+
+
+class BasicAuthentication(Authentication):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['authorization'] = 'Basic ' + base64.encodestring("%s:%s" % self.credentials).strip()
+
+
+class DigestAuthentication(Authentication):
+ """Only do qop='auth' and MD5, since that
+ is all Apache currently implements"""
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ self.challenge = challenge['digest']
+ qop = self.challenge.get('qop')
+ self.challenge['qop'] = ('auth' in [x.strip() for x in qop.split()]) and 'auth' or None
+ if self.challenge['qop'] is None:
+ raise UnimplementedDigestAuthOptionError( _("Unsupported value for qop: %s." % qop))
+ self.challenge['algorithm'] = self.challenge.get('algorithm', 'MD5')
+ if self.challenge['algorithm'] != 'MD5':
+ raise UnimplementedDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm']))
+ self.A1 = "".join([self.credentials[0], ":", self.challenge['realm'], ":", self.credentials[1]])
+ self.challenge['nc'] = 1
+
+ def request(self, method, request_uri, headers, content, cnonce = None):
+ """Modify the request headers"""
+ H = lambda x: md5.new(x).hexdigest()
+ KD = lambda s, d: H("%s:%s" % (s, d))
+ A2 = "".join([method, ":", request_uri])
+ self.challenge['cnonce'] = cnonce or _cnonce()
+ request_digest = '"%s"' % KD(H(self.A1), "%s:%s:%s:%s:%s" % (self.challenge['nonce'],
+ '%08x' % self.challenge['nc'],
+ self.challenge['cnonce'],
+ self.challenge['qop'], H(A2)
+ ))
+ headers['Authorization'] = 'Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm=%s, response=%s, qop=%s, nc=%08x, cnonce="%s"' % (
+ self.credentials[0],
+ self.challenge['realm'],
+ self.challenge['nonce'],
+ request_uri,
+ self.challenge['algorithm'],
+ request_digest,
+ self.challenge['qop'],
+ self.challenge['nc'],
+ self.challenge['cnonce'],
+ )
+ self.challenge['nc'] += 1
+
+ def response(self, response, content):
+ if not response.has_key('authentication-info'):
+ challenge = _parse_www_authenticate(response, 'www-authenticate').get('digest', {})
+ if 'true' == challenge.get('stale'):
+ self.challenge['nonce'] = challenge['nonce']
+ self.challenge['nc'] = 1
+ return True
+ else:
+ updated_challenge = _parse_www_authenticate(response, 'authentication-info').get('digest', {})
+
+ if updated_challenge.has_key('nextnonce'):
+ self.challenge['nonce'] = updated_challenge['nextnonce']
+ self.challenge['nc'] = 1
+ return False
+
+
+class HmacDigestAuthentication(Authentication):
+ """Adapted from Robert Sayre's code and DigestAuthentication above."""
+ __author__ = "Thomas Broyer (t.broyer@ltgt.net)"
+
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ self.challenge = challenge['hmacdigest']
+ # TODO: self.challenge['domain']
+ self.challenge['reason'] = self.challenge.get('reason', 'unauthorized')
+ if self.challenge['reason'] not in ['unauthorized', 'integrity']:
+ self.challenge['reason'] = 'unauthorized'
+ self.challenge['salt'] = self.challenge.get('salt', '')
+ if not self.challenge.get('snonce'):
+ raise UnimplementedHmacDigestAuthOptionError( _("The challenge doesn't contain a server nonce, or this one is empty."))
+ self.challenge['algorithm'] = self.challenge.get('algorithm', 'HMAC-SHA-1')
+ if self.challenge['algorithm'] not in ['HMAC-SHA-1', 'HMAC-MD5']:
+ raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm']))
+ self.challenge['pw-algorithm'] = self.challenge.get('pw-algorithm', 'SHA-1')
+ if self.challenge['pw-algorithm'] not in ['SHA-1', 'MD5']:
+ raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for pw-algorithm: %s." % self.challenge['pw-algorithm']))
+ if self.challenge['algorithm'] == 'HMAC-MD5':
+ self.hashmod = md5
+ else:
+ self.hashmod = sha
+ if self.challenge['pw-algorithm'] == 'MD5':
+ self.pwhashmod = md5
+ else:
+ self.pwhashmod = sha
+ self.key = "".join([self.credentials[0], ":",
+ self.pwhashmod.new("".join([self.credentials[1], self.challenge['salt']])).hexdigest().lower(),
+ ":", self.challenge['realm']
+ ])
+ self.key = self.pwhashmod.new(self.key).hexdigest().lower()
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers"""
+ keys = _get_end2end_headers(headers)
+ keylist = "".join(["%s " % k for k in keys])
+ headers_val = "".join([headers[k] for k in keys])
+ created = time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
+ cnonce = _cnonce()
+ request_digest = "%s:%s:%s:%s:%s" % (method, request_uri, cnonce, self.challenge['snonce'], headers_val)
+ request_digest = hmac.new(self.key, request_digest, self.hashmod).hexdigest().lower()
+ headers['Authorization'] = 'HMACDigest username="%s", realm="%s", snonce="%s", cnonce="%s", uri="%s", created="%s", response="%s", headers="%s"' % (
+ self.credentials[0],
+ self.challenge['realm'],
+ self.challenge['snonce'],
+ cnonce,
+ request_uri,
+ created,
+ request_digest,
+ keylist,
+ )
+
+ def response(self, response, content):
+ challenge = _parse_www_authenticate(response, 'www-authenticate').get('hmacdigest', {})
+ if challenge.get('reason') in ['integrity', 'stale']:
+ return True
+ return False
+
+
+class WsseAuthentication(Authentication):
+ """This is thinly tested and should not be relied upon.
+ At this time there isn't any third party server to test against.
+ Blogger and TypePad implemented this algorithm at one point
+ but Blogger has since switched to Basic over HTTPS and
+ TypePad has implemented it wrong, by never issuing a 401
+ challenge but instead requiring your client to telepathically know that
+ their endpoint is expecting WSSE profile="UsernameToken"."""
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['Authorization'] = 'WSSE profile="UsernameToken"'
+ iso_now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ cnonce = _cnonce()
+ password_digest = _wsse_username_token(cnonce, iso_now, self.credentials[1])
+ headers['X-WSSE'] = 'UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s"' % (
+ self.credentials[0],
+ password_digest,
+ cnonce,
+ iso_now)
+
+class GoogleLoginAuthentication(Authentication):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ from urllib import urlencode
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ service = challenge['googlelogin'].get('service', 'xapi')
+ # Bloggger actually returns the service in the challenge
+ # For the rest we guess based on the URI
+ if service == 'xapi' and request_uri.find("calendar") > 0:
+ service = "cl"
+ # No point in guessing Base or Spreadsheet
+ #elif request_uri.find("spreadsheets") > 0:
+ # service = "wise"
+
+ auth = dict(Email=credentials[0], Passwd=credentials[1], service=service, source=headers['user-agent'])
+ resp, content = self.http.request("https://www.google.com/accounts/ClientLogin", method="POST", body=urlencode(auth), headers={'Content-Type': 'application/x-www-form-urlencoded'})
+ lines = content.split('\n')
+ d = dict([tuple(line.split("=", 1)) for line in lines if line])
+ if resp.status == 403:
+ self.Auth = ""
+ else:
+ self.Auth = d['Auth']
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['authorization'] = 'GoogleLogin Auth=' + self.Auth
+
+
+AUTH_SCHEME_CLASSES = {
+ "basic": BasicAuthentication,
+ "wsse": WsseAuthentication,
+ "digest": DigestAuthentication,
+ "hmacdigest": HmacDigestAuthentication,
+ "googlelogin": GoogleLoginAuthentication
+}
+
+AUTH_SCHEME_ORDER = ["hmacdigest", "googlelogin", "digest", "wsse", "basic"]
+
+def _md5(s):
+ return
+
+class FileCache(object):
+ """Uses a local directory as a store for cached files.
+ Not really safe to use if multiple threads or processes are going to
+ be running on the same cache.
+ """
+ def __init__(self, cache, safe=safename): # use safe=lambda x: md5.new(x).hexdigest() for the old behavior
+ self.cache = cache
+ self.safe = safe
+ if not os.path.exists(cache):
+ os.makedirs(self.cache)
+
+ def get(self, key):
+ retval = None
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ try:
+ f = file(cacheFullPath, "r")
+ retval = f.read()
+ f.close()
+ except IOError:
+ pass
+ return retval
+
+ def set(self, key, value):
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ f = file(cacheFullPath, "w")
+ f.write(value)
+ f.close()
+
+ def delete(self, key):
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ if os.path.exists(cacheFullPath):
+ os.remove(cacheFullPath)
+
+class Credentials(object):
+ def __init__(self):
+ self.credentials = []
+
+ def add(self, name, password, domain=""):
+ self.credentials.append((domain.lower(), name, password))
+
+ def clear(self):
+ self.credentials = []
+
+ def iter(self, domain):
+ for (cdomain, name, password) in self.credentials:
+ if cdomain == "" or domain == cdomain:
+ yield (name, password)
+
+class KeyCerts(Credentials):
+ """Identical to Credentials except that
+ name/password are mapped to key/cert."""
+ pass
+
+
+class ProxyInfo(object):
+ """Collect information required to use a proxy."""
+ def __init__(self, proxy_type, proxy_host, proxy_port, proxy_rdns=None, proxy_user=None, proxy_pass=None):
+ """The parameter proxy_type must be set to one of socks.PROXY_TYPE_XXX
+ constants. For example:
+
+p = ProxyInfo(proxy_type=socks.PROXY_TYPE_HTTP, proxy_host='localhost', proxy_port=8000)
+ """
+ self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, self.proxy_user, self.proxy_pass = proxy_type, proxy_host, proxy_port, proxy_rdns, proxy_user, proxy_pass
+
+ def astuple(self):
+ return (self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns,
+ self.proxy_user, self.proxy_pass)
+
+ def isgood(self):
+ return socks and (self.proxy_host != None) and (self.proxy_port != None)
+
+
+class HTTPConnectionWithTimeout(httplib.HTTPConnection):
+ """HTTPConnection subclass that supports timeouts"""
+
+ def __init__(self, host, port=None, strict=None, timeout=None, proxy_info=None):
+ httplib.HTTPConnection.__init__(self, host, port, strict)
+ self.timeout = timeout
+ self.proxy_info = proxy_info
+
+ def connect(self):
+ """Connect to the host and port specified in __init__."""
+ # Mostly verbatim from httplib.py.
+ msg = "getaddrinfo returns an empty list"
+ for res in socket.getaddrinfo(self.host, self.port, 0,
+ socket.SOCK_STREAM):
+ af, socktype, proto, canonname, sa = res
+ try:
+ if self.proxy_info and self.proxy_info.isgood():
+ self.sock = socks.socksocket(af, socktype, proto)
+ self.sock.setproxy(*self.proxy_info.astuple())
+ else:
+ self.sock = socket.socket(af, socktype, proto)
+ # Different from httplib: support timeouts.
+ if self.timeout is not None:
+ self.sock.settimeout(self.timeout)
+ # End of difference from httplib.
+ if self.debuglevel > 0:
+ print "connect: (%s, %s)" % (self.host, self.port)
+ self.sock.connect(sa)
+ except socket.error, msg:
+ if self.debuglevel > 0:
+ print 'connect fail:', (self.host, self.port)
+ if self.sock:
+ self.sock.close()
+ self.sock = None
+ continue
+ break
+ if not self.sock:
+ raise socket.error, msg
+
+class HTTPSConnectionWithTimeout(httplib.HTTPSConnection):
+ "This class allows communication via SSL."
+
+ def __init__(self, host, port=None, key_file=None, cert_file=None,
+ strict=None, timeout=None, proxy_info=None):
+ self.timeout = timeout
+ self.proxy_info = proxy_info
+ httplib.HTTPSConnection.__init__(self, host, port=port, key_file=key_file,
+ cert_file=cert_file, strict=strict)
+
+ def connect(self):
+ "Connect to a host on a given (SSL) port."
+
+ if self.proxy_info and self.proxy_info.isgood():
+ self.sock.setproxy(*self.proxy_info.astuple())
+ sock.setproxy(*self.proxy_info.astuple())
+ else:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ if self.timeout is not None:
+ sock.settimeout(self.timeout)
+ sock.connect((self.host, self.port))
+ ssl = socket.ssl(sock, self.key_file, self.cert_file)
+ self.sock = httplib.FakeSocket(sock, ssl)
+
+
+
+class Http(object):
+ """An HTTP client that handles:
+- all methods
+- caching
+- ETags
+- compression,
+- HTTPS
+- Basic
+- Digest
+- WSSE
+
+and more.
+ """
+ def __init__(self, cache=None, timeout=None, proxy_info=None):
+ """The value of proxy_info is a ProxyInfo instance.
+
+If 'cache' is a string then it is used as a directory name
+for a disk cache. Otherwise it must be an object that supports
+the same interface as FileCache."""
+ self.proxy_info = proxy_info
+ # Map domain name to an httplib connection
+ self.connections = {}
+ # The location of the cache, for now a directory
+ # where cached responses are held.
+ if cache and isinstance(cache, str):
+ self.cache = FileCache(cache)
+ else:
+ self.cache = cache
+
+ # Name/password
+ self.credentials = Credentials()
+
+ # Key/cert
+ self.certificates = KeyCerts()
+
+ # authorization objects
+ self.authorizations = []
+
+ # If set to False then no redirects are followed, even safe ones.
+ self.follow_redirects = True
+
+ # If 'follow_redirects' is True, and this is set to True then
+ # all redirecs are followed, including unsafe ones.
+ self.follow_all_redirects = False
+
+ self.ignore_etag = False
+
+ self.force_exception_to_status_code = False
+
+ self.timeout = timeout
+
+ def _auth_from_challenge(self, host, request_uri, headers, response, content):
+ """A generator that creates Authorization objects
+ that can be applied to requests.
+ """
+ challenges = _parse_www_authenticate(response, 'www-authenticate')
+ for cred in self.credentials.iter(host):
+ for scheme in AUTH_SCHEME_ORDER:
+ if challenges.has_key(scheme):
+ yield AUTH_SCHEME_CLASSES[scheme](cred, host, request_uri, headers, response, content, self)
+
+ def add_credentials(self, name, password, domain=""):
+ """Add a name and password that will be used
+ any time a request requires authentication."""
+ self.credentials.add(name, password, domain)
+
+ def add_certificate(self, key, cert, domain):
+ """Add a key and cert that will be used
+ any time a request requires authentication."""
+ self.certificates.add(key, cert, domain)
+
+ def clear_credentials(self):
+ """Remove all the names and passwords
+ that are used for authentication"""
+ self.credentials.clear()
+ self.authorizations = []
+
+ def _conn_request(self, conn, request_uri, method, body, headers):
+ for i in range(2):
+ try:
+ conn.request(method, request_uri, body, headers)
+ response = conn.getresponse()
+ except socket.gaierror:
+ conn.close()
+ raise ServerNotFoundError("Unable to find the server at %s" % conn.host)
+ except httplib.HTTPException, e:
+ if i == 0:
+ conn.close()
+ conn.connect()
+ continue
+ else:
+ raise
+ else:
+ content = response.read()
+ response = Response(response)
+ if method != "HEAD":
+ content = _decompressContent(response, content)
+
+ break;
+ return (response, content)
+
+
+ def _request(self, conn, host, absolute_uri, request_uri, method, body, headers, redirections, cachekey):
+ """Do the actual request using the connection object
+ and also follow one level of redirects if necessary"""
+
+ auths = [(auth.depth(request_uri), auth) for auth in self.authorizations if auth.inscope(host, request_uri)]
+ auth = auths and sorted(auths)[0][1] or None
+ if auth:
+ auth.request(method, request_uri, headers, body)
+
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers)
+
+ if auth:
+ if auth.response(response, body):
+ auth.request(method, request_uri, headers, body)
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers )
+ response._stale_digest = 1
+
+ if response.status == 401:
+ for authorization in self._auth_from_challenge(host, request_uri, headers, response, content):
+ authorization.request(method, request_uri, headers, body)
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers, )
+ if response.status != 401:
+ self.authorizations.append(authorization)
+ authorization.response(response, body)
+ break
+
+ if (self.follow_all_redirects or (method in ["GET", "HEAD"]) or response.status == 303):
+ if self.follow_redirects and response.status in [300, 301, 302, 303, 307]:
+ # Pick out the location header and basically start from the beginning
+ # remembering first to strip the ETag header and decrement our 'depth'
+ if redirections:
+ if not response.has_key('location') and response.status != 300:
+ raise RedirectMissingLocation( _("Redirected but the response is missing a Location: header."), response, content)
+ # Fix-up relative redirects (which violate an RFC 2616 MUST)
+ if response.has_key('location'):
+ location = response['location']
+ (scheme, authority, path, query, fragment) = parse_uri(location)
+ if authority == None:
+ response['location'] = urlparse.urljoin(absolute_uri, location)
+ if response.status == 301 and method in ["GET", "HEAD"]:
+ response['-x-permanent-redirect-url'] = response['location']
+ if not response.has_key('content-location'):
+ response['content-location'] = absolute_uri
+ _updateCache(headers, response, content, self.cache, cachekey)
+ if headers.has_key('if-none-match'):
+ del headers['if-none-match']
+ if headers.has_key('if-modified-since'):
+ del headers['if-modified-since']
+ if response.has_key('location'):
+ location = response['location']
+ old_response = copy.deepcopy(response)
+ if not old_response.has_key('content-location'):
+ old_response['content-location'] = absolute_uri
+ redirect_method = ((response.status == 303) and (method not in ["GET", "HEAD"])) and "GET" or method
+ (response, content) = self.request(location, redirect_method, body=body, headers = headers, redirections = redirections - 1)
+ response.previous = old_response
+ else:
+ raise RedirectLimit( _("Redirected more times than rediection_limit allows."), response, content)
+ elif response.status in [200, 203] and method == "GET":
+ # Don't cache 206's since we aren't going to handle byte range requests
+ if not response.has_key('content-location'):
+ response['content-location'] = absolute_uri
+ _updateCache(headers, response, content, self.cache, cachekey)
+
+ return (response, content)
+
+
+# Need to catch and rebrand some exceptions
+# Then need to optionally turn all exceptions into status codes
+# including all socket.* and httplib.* exceptions.
+
+
+ def request(self, uri, method="GET", body=None, headers=None, redirections=DEFAULT_MAX_REDIRECTS, connection_type=None):
+ """ Performs a single HTTP request.
+The 'uri' is the URI of the HTTP resource and can begin
+with either 'http' or 'https'. The value of 'uri' must be an absolute URI.
+
+The 'method' is the HTTP method to perform, such as GET, POST, DELETE, etc.
+There is no restriction on the methods allowed.
+
+The 'body' is the entity body to be sent with the request. It is a string
+object.
+
+Any extra headers that are to be sent with the request should be provided in the
+'headers' dictionary.
+
+The maximum number of redirect to follow before raising an
+exception is 'redirections. The default is 5.
+
+The return value is a tuple of (response, content), the first
+being and instance of the 'Response' class, the second being
+a string that contains the response entity body.
+ """
+ try:
+ if headers is None:
+ headers = {}
+ else:
+ headers = _normalize_headers(headers)
+
+ if not headers.has_key('user-agent'):
+ headers['user-agent'] = "Python-httplib2/%s" % __version__
+
+ uri = iri2uri(uri)
+
+ (scheme, authority, request_uri, defrag_uri) = urlnorm(uri)
+
+ conn_key = scheme+":"+authority
+ if conn_key in self.connections:
+ conn = self.connections[conn_key]
+ else:
+ if not connection_type:
+ connection_type = (scheme == 'https') and HTTPSConnectionWithTimeout or HTTPConnectionWithTimeout
+ certs = list(self.certificates.iter(authority))
+ if scheme == 'https' and certs:
+ conn = self.connections[conn_key] = connection_type(authority, key_file=certs[0][0],
+ cert_file=certs[0][1], timeout=self.timeout, proxy_info=self.proxy_info)
+ else:
+ conn = self.connections[conn_key] = connection_type(authority, timeout=self.timeout, proxy_info=self.proxy_info)
+ conn.set_debuglevel(debuglevel)
+
+ if method in ["GET", "HEAD"] and 'range' not in headers:
+ headers['accept-encoding'] = 'compress, gzip'
+
+ info = email.Message.Message()
+ cached_value = None
+ if self.cache:
+ cachekey = defrag_uri
+ cached_value = self.cache.get(cachekey)
+ if cached_value:
+ info = email.message_from_string(cached_value)
+ try:
+ content = cached_value.split('\r\n\r\n', 1)[1]
+ except IndexError:
+ self.cache.delete(cachekey)
+ cachekey = None
+ cached_value = None
+ else:
+ cachekey = None
+
+ if method in ["PUT"] and self.cache and info.has_key('etag') and not self.ignore_etag and 'if-match' not in headers:
+ # http://www.w3.org/1999/04/Editing/
+ headers['if-match'] = info['etag']
+
+ if method not in ["GET", "HEAD"] and self.cache and cachekey:
+ # RFC 2616 Section 13.10
+ self.cache.delete(cachekey)
+
+ if cached_value and method in ["GET", "HEAD"] and self.cache and 'range' not in headers:
+ if info.has_key('-x-permanent-redirect-url'):
+ # Should cached permanent redirects be counted in our redirection count? For now, yes.
+ (response, new_content) = self.request(info['-x-permanent-redirect-url'], "GET", headers = headers, redirections = redirections - 1)
+ response.previous = Response(info)
+ response.previous.fromcache = True
+ else:
+ # Determine our course of action:
+ # Is the cached entry fresh or stale?
+ # Has the client requested a non-cached response?
+ #
+ # There seems to be three possible answers:
+ # 1. [FRESH] Return the cache entry w/o doing a GET
+ # 2. [STALE] Do the GET (but add in cache validators if available)
+ # 3. [TRANSPARENT] Do a GET w/o any cache validators (Cache-Control: no-cache) on the request
+ entry_disposition = _entry_disposition(info, headers)
+
+ if entry_disposition == "FRESH":
+ if not cached_value:
+ info['status'] = '504'
+ content = ""
+ response = Response(info)
+ if cached_value:
+ response.fromcache = True
+ return (response, content)
+
+ if entry_disposition == "STALE":
+ if info.has_key('etag') and not self.ignore_etag and not 'if-none-match' in headers:
+ headers['if-none-match'] = info['etag']
+ if info.has_key('last-modified') and not 'last-modified' in headers:
+ headers['if-modified-since'] = info['last-modified']
+ elif entry_disposition == "TRANSPARENT":
+ pass
+
+ (response, new_content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
+
+ if response.status == 304 and method == "GET":
+ # Rewrite the cache entry with the new end-to-end headers
+ # Take all headers that are in response
+ # and overwrite their values in info.
+ # unless they are hop-by-hop, or are listed in the connection header.
+
+ for key in _get_end2end_headers(response):
+ info[key] = response[key]
+ merged_response = Response(info)
+ if hasattr(response, "_stale_digest"):
+ merged_response._stale_digest = response._stale_digest
+ _updateCache(headers, merged_response, content, self.cache, cachekey)
+ response = merged_response
+ response.status = 200
+ response.fromcache = True
+
+ elif response.status == 200:
+ content = new_content
+ else:
+ self.cache.delete(cachekey)
+ content = new_content
+ else:
+ (response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
+ except Exception, e:
+ if self.force_exception_to_status_code:
+ if isinstance(e, HttpLib2ErrorWithResponse):
+ response = e.response
+ content = e.content
+ response.status = 500
+ response.reason = str(e)
+ elif isinstance(e, socket.timeout):
+ content = "Request Timeout"
+ response = Response( {
+ "content-type": "text/plain",
+ "status": "408",
+ "content-length": len(content)
+ })
+ response.reason = "Request Timeout"
+ else:
+ content = str(e)
+ response = Response( {
+ "content-type": "text/plain",
+ "status": "400",
+ "content-length": len(content)
+ })
+ response.reason = "Bad Request"
+ else:
+ raise
+
+
+ return (response, content)
+
+
+
+class Response(dict):
+ """An object more like email.Message than httplib.HTTPResponse."""
+
+ """Is this response from our local cache"""
+ fromcache = False
+
+ """HTTP protocol version used by server. 10 for HTTP/1.0, 11 for HTTP/1.1. """
+ version = 11
+
+ "Status code returned by server. "
+ status = 200
+
+ """Reason phrase returned by server."""
+ reason = "Ok"
+
+ previous = None
+
+ def __init__(self, info):
+ # info is either an email.Message or
+ # an httplib.HTTPResponse object.
+ if isinstance(info, httplib.HTTPResponse):
+ for key, value in info.getheaders():
+ self[key] = value
+ self.status = info.status
+ self['status'] = str(self.status)
+ self.reason = info.reason
+ self.version = info.version
+ elif isinstance(info, email.Message.Message):
+ for key, value in info.items():
+ self[key] = value
+ self.status = int(self['status'])
+ else:
+ for key, value in info.iteritems():
+ self[key] = value
+ self.status = int(self.get('status', self.status))
+
+
+ def __getattr__(self, name):
+ if name == 'dict':
+ return self
+ else:
+ raise AttributeError, name
diff --git a/tutorius/apilib/httplib2/__init__.pyc b/tutorius/apilib/httplib2/__init__.pyc
new file mode 100644
index 0000000..e5f8ebe
--- /dev/null
+++ b/tutorius/apilib/httplib2/__init__.pyc
Binary files differ
diff --git a/tutorius/apilib/httplib2/iri2uri.py b/tutorius/apilib/httplib2/iri2uri.py
new file mode 100644
index 0000000..70667ed
--- /dev/null
+++ b/tutorius/apilib/httplib2/iri2uri.py
@@ -0,0 +1,110 @@
+"""
+iri2uri
+
+Converts an IRI to a URI.
+
+"""
+__author__ = "Joe Gregorio (joe@bitworking.org)"
+__copyright__ = "Copyright 2006, Joe Gregorio"
+__contributors__ = []
+__version__ = "1.0.0"
+__license__ = "MIT"
+__history__ = """
+"""
+
+import urlparse
+
+
+# Convert an IRI to a URI following the rules in RFC 3987
+#
+# The characters we need to enocde and escape are defined in the spec:
+#
+# iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD
+# ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF
+# / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD
+# / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD
+# / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD
+# / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD
+# / %xD0000-DFFFD / %xE1000-EFFFD
+
+escape_range = [
+ (0xA0, 0xD7FF ),
+ (0xE000, 0xF8FF ),
+ (0xF900, 0xFDCF ),
+ (0xFDF0, 0xFFEF),
+ (0x10000, 0x1FFFD ),
+ (0x20000, 0x2FFFD ),
+ (0x30000, 0x3FFFD),
+ (0x40000, 0x4FFFD ),
+ (0x50000, 0x5FFFD ),
+ (0x60000, 0x6FFFD),
+ (0x70000, 0x7FFFD ),
+ (0x80000, 0x8FFFD ),
+ (0x90000, 0x9FFFD),
+ (0xA0000, 0xAFFFD ),
+ (0xB0000, 0xBFFFD ),
+ (0xC0000, 0xCFFFD),
+ (0xD0000, 0xDFFFD ),
+ (0xE1000, 0xEFFFD),
+ (0xF0000, 0xFFFFD ),
+ (0x100000, 0x10FFFD)
+]
+
+def encode(c):
+ retval = c
+ i = ord(c)
+ for low, high in escape_range:
+ if i < low:
+ break
+ if i >= low and i <= high:
+ retval = "".join(["%%%2X" % ord(o) for o in c.encode('utf-8')])
+ break
+ return retval
+
+
+def iri2uri(uri):
+ """Convert an IRI to a URI. Note that IRIs must be
+ passed in a unicode strings. That is, do not utf-8 encode
+ the IRI before passing it into the function."""
+ if isinstance(uri ,unicode):
+ (scheme, authority, path, query, fragment) = urlparse.urlsplit(uri)
+ authority = authority.encode('idna')
+ # For each character in 'ucschar' or 'iprivate'
+ # 1. encode as utf-8
+ # 2. then %-encode each octet of that utf-8
+ uri = urlparse.urlunsplit((scheme, authority, path, query, fragment))
+ uri = "".join([encode(c) for c in uri])
+ return uri
+
+if __name__ == "__main__":
+ import unittest
+
+ class Test(unittest.TestCase):
+
+ def test_uris(self):
+ """Test that URIs are invariant under the transformation."""
+ invariant = [
+ u"ftp://ftp.is.co.za/rfc/rfc1808.txt",
+ u"http://www.ietf.org/rfc/rfc2396.txt",
+ u"ldap://[2001:db8::7]/c=GB?objectClass?one",
+ u"mailto:John.Doe@example.com",
+ u"news:comp.infosystems.www.servers.unix",
+ u"tel:+1-816-555-1212",
+ u"telnet://192.0.2.16:80/",
+ u"urn:oasis:names:specification:docbook:dtd:xml:4.1.2" ]
+ for uri in invariant:
+ self.assertEqual(uri, iri2uri(uri))
+
+ def test_iri(self):
+ """ Test that the right type of escaping is done for each part of the URI."""
+ self.assertEqual("http://xn--o3h.com/%E2%98%84", iri2uri(u"http://\N{COMET}.com/\N{COMET}"))
+ self.assertEqual("http://bitworking.org/?fred=%E2%98%84", iri2uri(u"http://bitworking.org/?fred=\N{COMET}"))
+ self.assertEqual("http://bitworking.org/#%E2%98%84", iri2uri(u"http://bitworking.org/#\N{COMET}"))
+ self.assertEqual("#%E2%98%84", iri2uri(u"#\N{COMET}"))
+ self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}"))
+ self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}")))
+ self.assertNotEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}".encode('utf-8')))
+
+ unittest.main()
+
+
diff --git a/tutorius/apilib/httplib2/iri2uri.pyc b/tutorius/apilib/httplib2/iri2uri.pyc
new file mode 100644
index 0000000..879e719
--- /dev/null
+++ b/tutorius/apilib/httplib2/iri2uri.pyc
Binary files differ
diff --git a/tutorius/apilib/mimeTypes.py b/tutorius/apilib/mimeTypes.py
new file mode 100644
index 0000000..ff8f641
--- /dev/null
+++ b/tutorius/apilib/mimeTypes.py
@@ -0,0 +1,57 @@
+"""
+ Copyright (C) 2008 Benjamin O'Steen
+
+ This file is part of python-fedoracommons.
+
+ python-fedoracommons is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ python-fedoracommons 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with python-fedoracommons. If not, see <http://www.gnu.org/licenses/>.
+"""
+
+__license__ = 'GPL http://www.gnu.org/licenses/gpl.txt'
+__author__ = "Benjamin O'Steen <bosteen@gmail.com>"
+__version__ = '0.1'
+
+class mimeTypes(object):
+ def getDictionary(self):
+ mimetype_to_extension = {}
+ extension_to_mimetype = {}
+ mimetype_to_extension['text/plain'] = 'txt'
+ mimetype_to_extension['text/xml'] = 'xml'
+ mimetype_to_extension['text/css'] = 'css'
+ mimetype_to_extension['text/javascript'] = 'js'
+ mimetype_to_extension['text/rtf'] = 'rtf'
+ mimetype_to_extension['text/calendar'] = 'ics'
+ mimetype_to_extension['application/msword'] = 'doc'
+ mimetype_to_extension['application/msexcel'] = 'xls'
+ mimetype_to_extension['application/x-msword'] = 'doc'
+ mimetype_to_extension['application/vnd.ms-excel'] = 'xls'
+ mimetype_to_extension['application/vnd.ms-powerpoint'] = 'ppt'
+ mimetype_to_extension['application/pdf'] = 'pdf'
+ mimetype_to_extension['text/comma-separated-values'] = 'csv'
+
+
+ mimetype_to_extension['image/jpeg'] = 'jpg'
+ mimetype_to_extension['image/gif'] = 'gif'
+ mimetype_to_extension['image/jpg'] = 'jpg'
+ mimetype_to_extension['image/tiff'] = 'tiff'
+ mimetype_to_extension['image/png'] = 'png'
+
+ # And hacky reverse lookups
+ for mimetype in mimetype_to_extension:
+ extension_to_mimetype[mimetype_to_extension[mimetype]] = mimetype
+
+ mimetype_extension_mapping = {}
+ mimetype_extension_mapping.update(mimetype_to_extension)
+ mimetype_extension_mapping.update(extension_to_mimetype)
+
+ return mimetype_extension_mapping
diff --git a/tutorius/apilib/mimeTypes.pyc b/tutorius/apilib/mimeTypes.pyc
new file mode 100644
index 0000000..35ef2b2
--- /dev/null
+++ b/tutorius/apilib/mimeTypes.pyc
Binary files differ
diff --git a/tutorius/apilib/restful_lib.py b/tutorius/apilib/restful_lib.py
new file mode 100644
index 0000000..e1ee0af
--- /dev/null
+++ b/tutorius/apilib/restful_lib.py
@@ -0,0 +1,129 @@
+"""
+ Copyright (C) 2008 Benjamin O'Steen
+
+ This file is part of python-fedoracommons.
+
+ python-fedoracommons is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ python-fedoracommons 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with python-fedoracommons. If not, see <http://www.gnu.org/licenses/>.
+"""
+
+__license__ = 'GPL http://www.gnu.org/licenses/gpl.txt'
+__author__ = "Benjamin O'Steen <bosteen@gmail.com>"
+__version__ = '0.1'
+
+import httplib2
+import urlparse
+import urllib
+import base64
+from base64 import encodestring
+
+from mimeTypes import *
+
+import mimetypes
+
+from cStringIO import StringIO
+
+class Connection:
+ def __init__(self, base_url, username=None, password=None):
+ self.base_url = base_url
+ self.username = username
+ m = mimeTypes()
+ self.mimetypes = m.getDictionary()
+
+ self.url = urlparse.urlparse(base_url)
+
+ (scheme, netloc, path, query, fragment) = urlparse.urlsplit(base_url)
+
+ self.scheme = scheme
+ self.host = netloc
+ self.path = path
+
+ # Create Http class with support for Digest HTTP Authentication, if necessary
+ self.h = httplib2.Http(".cache")
+ self.h.follow_all_redirects = True
+ if username and password:
+ self.h.add_credentials(username, password)
+
+ def request_get(self, resource, args = None, headers={}):
+ return self.request(resource, "get", args, headers=headers)
+
+ def request_delete(self, resource, args = None, headers={}):
+ return self.request(resource, "delete", args, headers=headers)
+
+ def request_head(self, resource, args = None, headers={}):
+ return self.request(resource, "head", args, headers=headers)
+
+ def request_post(self, resource, args = None, body = None, filename=None, headers={}):
+ return self.request(resource, "post", args , body = body, filename=filename, headers=headers)
+
+ def request_put(self, resource, args = None, body = None, filename=None, headers={}):
+ return self.request(resource, "put", args , body = body, filename=filename, headers=headers)
+
+ def get_content_type(self, filename):
+ extension = filename.split('.')[-1]
+ guessed_mimetype = self.mimetypes.get(extension, mimetypes.guess_type(filename)[0])
+ return guessed_mimetype or 'application/octet-stream'
+
+ def request(self, resource, method = "get", args = None, body = None, filename=None, headers={}):
+ params = None
+ path = resource
+ headers['User-Agent'] = 'Basic Agent'
+
+ BOUNDARY = u'00hoYUXOnLD5RQ8SKGYVgLLt64jejnMwtO7q8XE1'
+ CRLF = u'\r\n'
+
+ if filename and body:
+ #fn = open(filename ,'r')
+ #chunks = fn.read()
+ #fn.close()
+
+ # Attempt to find the Mimetype
+ content_type = self.get_content_type(filename)
+ headers['Content-Type']='multipart/form-data; boundary='+BOUNDARY
+ encode_string = StringIO()
+ encode_string.write(CRLF)
+ encode_string.write(u'--' + BOUNDARY + CRLF)
+ encode_string.write(u'Content-Disposition: form-data; name="file"; filename="%s"' % filename)
+ encode_string.write(CRLF)
+ encode_string.write(u'Content-Type: %s' % content_type + CRLF)
+ encode_string.write(CRLF)
+ encode_string.write(body)
+ encode_string.write(CRLF)
+ encode_string.write(u'--' + BOUNDARY + u'--' + CRLF)
+
+ body = encode_string.getvalue()
+ headers['Content-Length'] = str(len(body))
+ elif body:
+ if not headers.get('Content-Type', None):
+ headers['Content-Type']='text/xml'
+ headers['Content-Length'] = str(len(body))
+ else:
+ headers['Content-Type']='text/xml'
+
+ if args:
+ path += u"?" + urllib.urlencode(args)
+
+ request_path = []
+ if self.path != "/":
+ if self.path.endswith('/'):
+ request_path.append(self.path[:-1])
+ else:
+ request_path.append(self.path)
+ if path.startswith('/'):
+ request_path.append(path[1:])
+ else:
+ request_path.append(path)
+
+ resp, content = self.h.request(u"%s://%s%s" % (self.scheme, self.host, u'/'.join(request_path)), method.upper(), body=body, headers=headers )
+
+ return {u'headers':resp, u'body':content.decode('UTF-8')}
diff --git a/tutorius/apilib/restful_lib.pyc b/tutorius/apilib/restful_lib.pyc
new file mode 100644
index 0000000..5b06765
--- /dev/null
+++ b/tutorius/apilib/restful_lib.pyc
Binary files differ
diff --git a/tutorius/bundler.py b/tutorius/bundler.py
deleted file mode 100644
index d374924..0000000
--- a/tutorius/bundler.py
+++ /dev/null
@@ -1,573 +0,0 @@
-# Copyright (C) 2009, Tutorius.org
-# Copyright (C) 2009, Jean-Christophe Savard <savard.jean.christophe@gmail.com>
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
-#
-# This program 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 General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
-
-
-"""
-This module contains all the data handling class of Tutorius
-"""
-
-import logging
-import os
-import uuid
-import xml.dom.minidom
-from xml.dom import NotFoundErr
-
-from sugar.tutorius import addon
-from sugar.tutorius.core import Tutorial, State, FiniteStateMachine
-from sugar.tutorius.filters import *
-from sugar.tutorius.actions import *
-from ConfigParser import SafeConfigParser
-
-# this is where user installed/generated tutorials will go
-def _get_store_root():
- profile_name = os.getenv("SUGAR_PROFILE") or "default"
- return os.path.join(os.getenv("HOME"),
- ".sugar",profile_name,"tutorius","data")
-# this is where activity bundled tutorials should be, under the activity bundle
-def _get_bundle_root(base_path=None):
- base_path = base_path or os.getenv("SUGAR_BUNDLE_PATH")
- if base_path:
- return os.path.join(os.getenv("SUGAR_BUNDLE_PATH"),"data","tutorius","data")
-
-INI_ACTIVITY_SECTION = "RELATED_ACTIVITIES"
-INI_METADATA_SECTION = "GENERAL_METADATA"
-INI_GUID_PROPERTY = "GUID"
-INI_NAME_PROPERTY = "NAME"
-INI_XML_FSM_PROPERTY = "FSM_FILENAME"
-INI_FILENAME = "meta.ini"
-TUTORIAL_FILENAME = "tutorial.xml"
-NODE_COMPONENT = "Component"
-NEXT_STATE_ATTR = "next_state"
-
-class TutorialStore(object):
-
- def list_available_tutorials(self, activity_name, activity_vers):
- """
- Generate the list of all tutorials present on disk for a
- given activity.
-
- @returns a map of tutorial {names : GUID}.
- """
- # check both under the activity data and user installed folders
- paths = [p for p in [_get_store_root(), _get_bundle_root()] if p ]
-
- tutoGuidName = {}
-
- for repository in paths:
- # (our) convention dictates that tutorial folders are named
- # with their GUID (for unicity) but this is not enforced.
- try:
- for tuto in os.listdir(repository):
- parser = SafeConfigParser()
- parser.read(os.path.join(repository, tuto, INI_FILENAME))
- guid = parser.get(INI_METADATA_SECTION, INI_GUID_PROPERTY)
- name = parser.get(INI_METADATA_SECTION, INI_NAME_PROPERTY)
- activities = parser.options(INI_ACTIVITY_SECTION)
- # enforce matching activity name AND version, as UI changes
- # break tutorials. We may lower this requirement when the
- # UAM gets less dependent on the widget order.
- # Also note property names are always stored lowercase.
- if activity_name.lower() in activities:
- version = parser.get(INI_ACTIVITY_SECTION, activity_name)
- if activity_vers == version:
- tutoGuidName[guid] = name
- except OSError:
- # the repository may not exist. Continue scanning
- pass
-
- return tutoGuidName
-
- def load_tutorial(self, Guid, bundle_path=None):
- """
- Rebuilds a tutorial object from it's serialized state.
- Common storing paths will be scanned.
-
- @param Guid the generic identifier of the tutorial
- @returns a Tutorial object containing an FSM
- """
- bundler = TutorialBundler(Guid, bundle_path=bundle_path)
- bundler_path = bundler.get_tutorial_path()
- config = SafeConfigParser()
- config.read(os.path.join(bundler_path, INI_FILENAME))
-
- serializer = XMLSerializer()
-
- name = config.get(INI_METADATA_SECTION, INI_NAME_PROPERTY)
- fsm = serializer.load_fsm(Guid, bundler.Path)
-
- tuto = Tutorial(name, fsm)
- return tuto
-
-
-class Serializer(object):
- """
- Interface that provide serializing and deserializing of the FSM
- used in the tutorials to/from disk. Must be inherited.
- """
-
- def save_fsm(self,fsm):
- """
- Save fsm to disk. If a GUID parameter is provided, the existing GUID is
- located in the .ini files in the store root and bundle root and
- the corresponding FSM is/are overwritten. If the GUId is not found, an
- exception occur. If no GUID is provided, FSM is written in a new file
- in the store root.
- """
- raise NotImplementedError()
-
- def load_fsm(self):
- """
- Load fsm from disk.
- """
- raise NotImplementedError()
-
-class XMLSerializer(Serializer):
- """
- Class that provide serializing and deserializing of the FSM
- used in the tutorials to/from a .xml file. Inherit from Serializer
- """
-
- def _create_state_dict_node(self, state_dict, doc):
- """
- Create and return a xml Node from a State dictionnary.
- """
- statesList = doc.createElement("States")
- for state_name, state in state_dict.items():
- stateNode = doc.createElement("State")
- statesList.appendChild(stateNode)
- stateNode.setAttribute("Name", state_name)
- actionsList = stateNode.appendChild(self._create_action_list_node(state.get_action_list(), doc))
- eventfiltersList = stateNode.appendChild(self._create_event_filters_node(state.get_event_filter_list(), doc))
- return statesList
-
- def _create_component_node(self, comp, doc):
- """
- Takes a single component (action or eventfilter) and transforms it
- into a xml node.
-
- @param comp A single component
- @param doc The XML document root (used to create nodes only
- @return A XML Node object with the component tag name
- """
- compNode = doc.createElement(NODE_COMPONENT)
-
- # Write down just the name of the Action class as the Class
- # property --
- compNode.setAttribute("Class",type(comp).__name__)
-
- # serialize all tutorius properties
- for propname in comp.get_properties():
- propval = getattr(comp, propname)
- if getattr(type(comp), propname).type == "addonlist":
- for subval in propval:
- compNode.appendChild(self._create_component_node(subval, doc))
- elif getattr(type(comp), propname).type == "addonlist":
- compNode.appendChild(self._create_component_node(subval, doc))
- else:
- # repr instead of str, as we want to be able to eval() it into a
- # valid object.
- compNode.setAttribute(propname, repr(propval))
-
- return compNode
-
- def _create_action_list_node(self, action_list, doc):
- """
- Create and return a xml Node from a Action list.
-
- @param action_list A list of actions
- @param doc The XML document root (used to create new nodes only)
- @return A XML Node object with the Actions tag name and a serie of
- Action children
- """
- actionsList = doc.createElement("Actions")
- for action in action_list:
- # Create the action node
- actionNode = self._create_component_node(action, doc)
- # Append it to the list
- actionsList.appendChild(actionNode)
-
- return actionsList
-
- def _create_event_filters_node(self, event_filters, doc):
- """
- Create and return a xml Node from a event filters.
- """
- eventFiltersList = doc.createElement("EventFiltersList")
- for event, state in event_filters:
- eventFilterNode = self._create_component_node(event, doc)
- eventFilterNode.setAttribute(NEXT_STATE_ATTR, str(state))
- eventFiltersList.appendChild(eventFilterNode)
-
- return eventFiltersList
-
- def save_fsm(self, fsm, xml_filename, path):
- """
- Save fsm to disk, in the xml file specified by "xml_filename", in the
- "path" folder. If the specified file doesn't exist, it will be created.
- """
- self.doc = doc = xml.dom.minidom.Document()
- fsm_element = doc.createElement("FSM")
- doc.appendChild(fsm_element)
- fsm_element.setAttribute("Name", fsm.name)
- fsm_element.setAttribute("StartStateName", fsm.start_state_name)
- statesDict = fsm_element.appendChild(self._create_state_dict_node(fsm._states, doc))
-
- fsm_actions_node = self._create_action_list_node(fsm.actions, doc)
- fsm_actions_node.tagName = "FSMActions"
- actionsList = fsm_element.appendChild(fsm_actions_node)
-
- file_object = open(os.path.join(path, xml_filename), "w")
- file_object.write(doc.toprettyxml())
- file_object.close()
-
-
- def _find_tutorial_dir_with_guid(self, guid):
- """
- Finds the tutorial with the associated GUID. If it is found, return
- the path to the tutorial's directory. If it doesn't exist, raise an
- IOError.
-
- A note : if there are two tutorials with this GUID in the folders,
- they will both be inspected and the one with the highest version
- number will be returned. If they have the same version number, the one
- from the global store will be returned.
-
- @param guid The GUID of the tutorial that is to be loaded.
- """
- # Attempt to find the tutorial's directory in the global directory
- global_dir = os.path.join(_get_store_root(), guid)
- # Then in the activty's bundle path
- activity_dir = os.path.join(_get_bundle_root(), guid)
-
- # If they both exist
- if os.path.isdir(global_dir) and os.path.isdir(activity_dir):
- # Inspect both metadata files
- global_meta = os.path.join(global_dir, "meta.ini")
- activity_meta = os.path.join(activity_dir, "meta.ini")
-
- # Open both config files
- global_parser = SafeConfigParser()
- global_parser.read(global_meta)
-
- activity_parser = SafeConfigParser()
- activity_parser.read(activity_meta)
-
- # Get the version number for each tutorial
- global_version = global_parser.get(INI_METADATA_SECTION, "version")
- activity_version = activity_parser.get(INI_METADATA_SECTION, "version")
-
- # If the global version is higher or equal, we'll take it
- if global_version >= activity_version:
- return global_dir
- else:
- return activity_dir
-
- # Do we just have the global directory?
- if os.path.isdir(global_dir):
- return global_dir
-
- # Or just the activity's bundle directory?
- if os.path.isdir(activity_dir):
- return activity_dir
-
- # Error : none of these directories contain the tutorial
- raise IOError(2, "Neither the global nor the bundle directory contained the tutorial with GUID %s"%guid)
-
- def _load_xml_properties(self, properties_elem):
- """
- Changes a list of properties into fully instanciated properties.
-
- @param properties_elem An XML element reprensenting a list of
- properties
- """
- return []
-
- def _load_xml_event_filters(self, filters_elem):
- """
- Loads up a list of Event Filters.
-
- @param filters_elem An XML Element representing a list of event filters
- """
- transition_list = []
- event_filter_element_list = filters_elem.getElementsByTagName(NODE_COMPONENT)
- new_event_filter = None
-
- for event_filter in event_filter_element_list:
- next_state = event_filter.getAttribute(NEXT_STATE_ATTR)
- try:
- event_filter.removeAttribute(NEXT_STATE_ATTR)
- except NotFoundErr:
- next_state = None
- new_event_filter = self._load_xml_component(event_filter)
-
- if new_event_filter is not None:
- transition_list.append((new_event_filter, next_state))
-
- return transition_list
-
- def _load_xml_component(self, node):
- """
- Loads a single addon component instance from an Xml node.
-
- @param node The component XML Node to transform
- object
- @return The addon component object of the correct type according to the XML
- description
- """
- new_action = addon.create(node.getAttribute("Class"))
- if not new_action:
- return None
-
- for attrib in node.attributes.keys():
- if attrib == "Class": continue
- # security note: keep sandboxed
- setattr(new_action, attrib, eval(node.getAttribute(attrib), {}, {}))
-
- # recreate complex attributes
- for sub in node.childNodes:
- name = getattr(new_action, sub.nodeName)
- if name == "addon":
- setattr(new_action, sub.getAttribute("Name"), self._load_xml_action(sub))
-
- return new_action
-
- def _load_xml_actions(self, actions_elem):
- """
- Transforms an Actions element into a list of instanciated Action.
-
- @param actions_elem An XML Element representing a list of Actions
- """
- reformed_actions_list = []
- actions_element_list = actions_elem.getElementsByTagName(NODE_COMPONENT)
-
- for action in actions_element_list:
- new_action = self._load_xml_component(action)
-
- reformed_actions_list.append(new_action)
-
- return reformed_actions_list
-
- def _load_xml_states(self, states_elem):
- """
- Takes in a States element and fleshes out a complete list of State
- objects.
-
- @param states_elem An XML Element that represents a list of States
- """
- reformed_state_list = []
- # item(0) because there is always only one <States> tag in the xml file
- # so states_elem should always contain only one element
- states_element_list = states_elem.item(0).getElementsByTagName("State")
-
- for state in states_element_list:
- stateName = state.getAttribute("Name")
- # Using item 0 in the list because there is always only one
- # Actions and EventFilterList element per State node.
- actions_list = self._load_xml_actions(state.getElementsByTagName("Actions")[0])
- event_filters_list = self._load_xml_event_filters(state.getElementsByTagName("EventFiltersList")[0])
- reformed_state_list.append(State(stateName, actions_list, event_filters_list))
-
- return reformed_state_list
-
- def _load_xml_fsm(self, fsm_elem):
- """
- Takes in an XML element representing an FSM and returns the fully
- crafted FSM.
-
- @param fsm_elem The XML element that describes a FSM
- """
- # Load the FSM's name and start state's name
- fsm_name = fsm_elem.getAttribute("Name")
-
- fsm_start_state_name = None
- try:
- fsm_start_state_name = fsm_elem.getAttribute("StartStateName")
- except:
- pass
-
- fsm = FiniteStateMachine(fsm_name, start_state_name=fsm_start_state_name)
-
- # Load the states
- states = self._load_xml_states(fsm_elem.getElementsByTagName("States"))
- for state in states:
- fsm.add_state(state)
-
- # Load the actions on this FSM
- actions = self._load_xml_actions(fsm_elem.getElementsByTagName("FSMActions")[0])
- for action in actions:
- fsm.add_action(action)
-
- # Load the event filters
- events = self._load_xml_event_filters(fsm_elem.getElementsByTagName("EventFiltersList")[0])
- for event, next_state in events:
- fsm.add_event_filter(event, next_state)
-
- return fsm
-
-
- def load_fsm(self, guid, path=None):
- """
- Load fsm from xml file whose .ini file guid match argument guid.
- """
- # Fetch the directory (if any)
- tutorial_dir = path or self._find_tutorial_dir_with_guid(guid)
-
- # Open the XML file
- tutorial_file = os.path.join(tutorial_dir, TUTORIAL_FILENAME)
-
- xml_dom = xml.dom.minidom.parse(tutorial_file)
-
- fsm_elem = xml_dom.getElementsByTagName("FSM")[0]
-
- return self._load_xml_fsm(fsm_elem)
-
-
-class TutorialBundler(object):
- """
- This class provide the various data handling methods useable by the tutorial
- editor.
- """
-
- def __init__(self,generated_guid = None, bundle_path=None):
- """
- Tutorial_bundler constructor. If a GUID is given in the parameter, the
- Tutorial_bundler object will be associated with it. If no GUID is given,
- a new GUID will be generated,
- """
-
- self.Guid = generated_guid or str(uuid.uuid1())
-
- #FIXME: Look for the bundle in the activity first (more specific)
- #Look for the file in the path if a uid is supplied
- if generated_guid:
- #General store
- store_path = os.path.join(_get_store_root(), generated_guid, INI_FILENAME)
- if os.path.isfile(store_path):
- self.Path = os.path.dirname(store_path)
- else:
- #Bundle store
- base_bundle_path = _get_bundle_root(bundle_path)
- if base_bundle_path:
- bundle_path = os.path.join(base_bundle_path, generated_guid, INI_FILENAME)
- if os.path.isfile(bundle_path):
- self.Path = os.path.dirname(bundle_path)
- else:
- raise IOError(2,"Unable to locate metadata file for guid '%s'" % generated_guid)
- else:
- raise IOError(2,"Unable to locate metadata file for guid '%s'" % generated_guid)
-
- else:
- #Create the folder, any failure will go through to the caller for now
- store_path = os.path.join(_get_store_root(), self.Guid)
- os.makedirs(store_path)
- self.Path = store_path
-
- def write_metadata_file(self, tutorial):
- """
- Write metadata to the property file.
- @param tutorial Tutorial for which to write metadata
- """
- #Create the Config Object and populate it
- cfg = SafeConfigParser()
- cfg.add_section(INI_METADATA_SECTION)
- cfg.set(INI_METADATA_SECTION, INI_GUID_PROPERTY, self.Guid)
- cfg.set(INI_METADATA_SECTION, INI_NAME_PROPERTY, tutorial.name)
- cfg.set(INI_METADATA_SECTION, INI_XML_FSM_PROPERTY, TUTORIAL_FILENAME)
- cfg.add_section(INI_ACTIVITY_SECTION)
- cfg.set(INI_ACTIVITY_SECTION, os.environ['SUGAR_BUNDLE_NAME'],
- os.environ['SUGAR_BUNDLE_VERSION'])
-
- #Write the ini file
- cfg.write( file( os.path.join(self.Path, INI_FILENAME), 'w' ) )
-
- def get_tutorial_path(self):
- """
- Return the path of the .ini file associated with the guiven guid set in
- the Guid property of the Tutorial_Bundler. If the guid is present in
- more than one path, the store_root is given priority.
- """
-
- store_root = _get_store_root()
- bundle_root = _get_bundle_root()
-
- config = SafeConfigParser()
- path = None
-
- logging.debug("************ Path of store_root folder of activity : " \
- + store_root)
-
- # iterate in each GUID subfolder
- for dir in os.listdir(store_root):
-
- # iterate for each .ini file in the store_root folder
-
- for file_name in os.listdir(os.path.join(store_root, dir)):
- if file_name.endswith(".ini"):
- logging.debug("******************* Found .ini file : " \
- + file_name)
- config.read(os.path.join(store_root, dir, file_name))
- if config.get(INI_METADATA_SECTION, INI_GUID_PROPERTY) == self.Guid:
- xml_filename = config.get(INI_METADATA_SECTION,
- INI_XML_FSM_PROPERTY)
-
- path = os.path.join(store_root, dir)
- return path
-
- logging.debug("************ Path of bundle_root folder of activity : " \
- + bundle_root)
-
-
- # iterate in each GUID subfolder
- for dir in os.listdir(bundle_root):
-
- # iterate for each .ini file in the bundle_root folder
- for file_name in os.listdir(os.path.join(bundle_root, dir)):
- if file_name.endswith(".ini"):
- logging.debug("******************* Found .ini file : " \
- + file_name)
- config.read(os.path.join(bundle_root, dir, file_name))
- if config.get(INI_METADATA_SECTION, INI_GUID_PROPERTY) == self.Guid:
- path = os.path.join(bundle_root, self.Guid)
- return path
-
- if path is None:
- logging.debug("**************** Error : GUID not found")
- raise KeyError
-
- def write_fsm(self, fsm):
-
- """
- Save fsm to disk. If a GUID parameter is provided, the existing GUID is
- located in the .ini files in the store root and bundle root and
- the corresponding FSM is/are created or overwritten. If the GUID is not
- found, an exception occur.
- """
-
- config = SafeConfigParser()
-
- serializer = XMLSerializer()
- path = os.path.join(self.Path, "meta.ini")
- config.read(path)
- xml_filename = config.get(INI_METADATA_SECTION, INI_XML_FSM_PROPERTY)
- serializer.save_fsm(fsm, xml_filename, self.Path)
-
-
- def add_resources(self, typename, file):
- """
- Add ressources to metadata.
- """
- raise NotImplementedError("add_resources not implemented")
diff --git a/tutorius/constraints.py b/tutorius/constraints.py
index e91f23a..cd71167 100644
--- a/tutorius/constraints.py
+++ b/tutorius/constraints.py
@@ -24,6 +24,14 @@ for some properties.
# For the File Constraint
import os
+# For the Resource Constraint
+import re
+
+class ConstraintException(Exception):
+ """
+ Parent class for all constraint exceptions
+ """
+ pass
class Constraint():
"""
@@ -47,7 +55,7 @@ class ValueConstraint(Constraint):
def __init__(self, limit):
self.limit = limit
-class UpperLimitConstraintError(Exception):
+class UpperLimitConstraintError(ConstraintException):
pass
class UpperLimitConstraint(ValueConstraint):
@@ -64,7 +72,7 @@ class UpperLimitConstraint(ValueConstraint):
raise UpperLimitConstraintError()
return
-class LowerLimitConstraintError(Exception):
+class LowerLimitConstraintError(ConstraintException):
pass
class LowerLimitConstraint(ValueConstraint):
@@ -81,7 +89,7 @@ class LowerLimitConstraint(ValueConstraint):
raise LowerLimitConstraintError()
return
-class MaxSizeConstraintError(Exception):
+class MaxSizeConstraintError(ConstraintException):
pass
class MaxSizeConstraint(ValueConstraint):
@@ -99,7 +107,7 @@ class MaxSizeConstraint(ValueConstraint):
raise MaxSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit))
return
-class MinSizeConstraintError(Exception):
+class MinSizeConstraintError(ConstraintException):
pass
class MinSizeConstraint(ValueConstraint):
@@ -117,7 +125,7 @@ class MinSizeConstraint(ValueConstraint):
raise MinSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit))
return
-class ColorConstraintError(Exception):
+class ColorConstraintError(ConstraintException):
pass
class ColorArraySizeError(ColorConstraintError):
@@ -153,7 +161,7 @@ class ColorConstraint(Constraint):
return
-class BooleanConstraintError(Exception):
+class BooleanConstraintError(ConstraintException):
pass
class BooleanConstraint(Constraint):
@@ -165,7 +173,7 @@ class BooleanConstraint(Constraint):
return
raise BooleanConstraintError("Value is not True or False")
-class EnumConstraintError(Exception):
+class EnumConstraintError(ConstraintException):
pass
class EnumConstraint(Constraint):
@@ -190,7 +198,7 @@ class EnumConstraint(Constraint):
raise EnumConstraintError("Value is not part of the enumeration")
return
-class FileConstraintError(Exception):
+class FileConstraintError(ConstraintException):
pass
class FileConstraint(Constraint):
@@ -208,3 +216,40 @@ class FileConstraint(Constraint):
raise FileConstraintError("Non-existing file : %s"%value)
return
+class ResourceConstraintError(ConstraintException):
+ pass
+
+class ResourceConstraint(Constraint):
+ """
+ Ensures that the value is looking like a resource name, like
+ <filename>_<GUID>[.<extension>]. We are not validating that this is a
+ valid resource for the reason that the property does not have any notion
+ of tutorial guid.
+
+ TODO : Find a way to properly validate resources by looking them up in the
+ Vault.
+ """
+
+ # Regular expression to parse a resource-like name
+ resource_regexp_text = "(.+)_([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})(\..*)?$"
+ resource_regexp = re.compile(resource_regexp_text)
+
+ def validate(self, value):
+ # TODO : Validate that we will not use an empty resource or if we can
+ # have transitory resource names
+ if value is None:
+ raise ResourceConstraintError("Resource not allowed to have a null value!")
+
+ # Special case : We allow the empty resource name for now
+ if value == "":
+ return value
+
+ # Attempt to see if the value has a resource name inside it
+ match = self.resource_regexp.search(value)
+
+ # If there was no match on the reg exp
+ if not match:
+ raise ResourceConstraintError("Resource name does not seem to be valid : %s" % value)
+
+ # If the name matched, then the value is _PROBABLY_ good
+ return value
diff --git a/tutorius/core.py b/tutorius/core.py
index c583a1f..80e1b4f 100644
--- a/tutorius/core.py
+++ b/tutorius/core.py
@@ -24,9 +24,9 @@ This module contains the core classes for tutorius
import logging
import os
-from sugar.tutorius.TProbe import ProbeManager
-from sugar.tutorius.dbustools import save_args
-from sugar.tutorius import addon
+from .TProbe import ProbeManager
+from .dbustools import save_args
+from . import addon
logger = logging.getLogger("tutorius")
@@ -91,7 +91,6 @@ class Tutorial (object):
self.state_machine.set_state(name)
-
def _prepare_activity(self):
"""
Prepare the activity for the tutorial by loading the saved state and
@@ -134,9 +133,6 @@ class State(object):
self._actions = action_list or []
- # Unused for now
- #self.tests = []
-
self._transitions= dict(event_filter_list or [])
self._installedEvents = set()
@@ -200,15 +196,13 @@ class State(object):
# These functions are used to simplify the creation of states
def add_action(self, new_action):
"""
- Adds an action to the state (only if it wasn't added before)
+ Adds an action to the state
@param new_action The new action to execute when in this state
@return True if added, False otherwise
"""
- if new_action not in self._actions:
- self._actions.append(new_action)
- return True
- return False
+ self._actions.append(new_action)
+ return True
# remove_action - We did not define names for the action, hence they're
# pretty hard to remove on a precise basis
@@ -255,6 +249,50 @@ class State(object):
tutorial.
"""
self._transitions = {}
+
+ def __eq__(self, otherState):
+ """
+ Compares two states and tells whether they contain the same states with the
+ same actions and event filters.
+
+ @param otherState The other State that we wish to match
+ @returns True if every action in this state has a matching action in the
+ other state with the same properties and values AND if every
+ event filters in this state has a matching filter in the
+ other state having the same properties and values AND if both
+ states have the same name.
+` """
+ if not isinstance(otherState, State):
+ return False
+ if self.name != otherState.name:
+ return False
+
+ # Do they have the same actions?
+ if len(self._actions) != len(otherState._actions):
+ return False
+
+ if len(self._transitions) != len(otherState._transitions):
+ return False
+
+ for act in self._actions:
+ found = False
+ # For each action in the other state, try to match it with this one.
+ for otherAct in otherState._actions:
+ if act == otherAct:
+ found = True
+ break
+ if found == False:
+ # If we arrive here, then we could not find an action with the
+ # same values in the other state. We know they're not identical
+ return False
+
+ # Do they have the same event filters?
+ if self._transitions != otherState._transitions:
+ return False
+
+ # If nothing failed up to now, then every actions and every filters can
+ # be found in the other state
+ return True
class FiniteStateMachine(State):
"""
@@ -467,8 +505,8 @@ class FiniteStateMachine(State):
#TODO : Move this code inside the State itself - we're breaking
# encap :P
- for event, state in st._transitions:
- if state == state_name:
+ for event in st._transitions:
+ if st._transitions[event] == state_name:
del st._transitions[event]
# Remove the state from the dictionary
@@ -487,7 +525,7 @@ class FiniteStateMachine(State):
next_states = set()
- for event, state in state._transitions:
+ for event, state in state._transitions.items():
next_states.add(state)
return tuple(next_states)
@@ -510,7 +548,7 @@ class FiniteStateMachine(State):
states = []
# Walk through the list of states
for st in self._states.itervalues():
- for event, state in st._transitions:
+ for event, state in st._transitions.items():
if state == state_name:
states.append(state)
continue
@@ -523,3 +561,80 @@ class FiniteStateMachine(State):
for st in self._states.itervalues():
out_string += st.name + ", "
return out_string
+
+ def __eq__(self, otherFSM):
+ """
+ Compares the elements of two FSM to ensure and returns true if they have the
+ same set of states, containing the same actions and the same event filters.
+
+ @returns True if the two FSMs have the same content, False otherwise
+ """
+ if not isinstance(otherFSM, FiniteStateMachine):
+ return False
+
+ # Make sure they share the same name
+ if not (self.name == otherFSM.name) or \
+ not (self.start_state_name == otherFSM.start_state_name):
+ return False
+
+ # Ensure they have the same number of FSM-level actions
+ if len(self._actions) != len(otherFSM._actions):
+ return False
+
+ # Test that we have all the same FSM level actions
+ for act in self._actions:
+ found = False
+ # For every action in the other FSM, try to match it with the
+ # current one.
+ for otherAct in otherFSM._actions:
+ if act == otherAct:
+ found = True
+ break
+ if found == False:
+ return False
+
+ # Make sure we have the same number of states in both FSMs
+ if len(self._states) != len(otherFSM._states):
+ return False
+
+ # For each state, try to find a corresponding state in the other FSM
+ for state_name in self._states.keys():
+ state = self._states[state_name]
+ other_state = None
+ try:
+ # Attempt to use this key in the other FSM. If it's not present
+ # the dictionary will throw an exception and we'll know we have
+ # at least one different state in the other FSM
+ other_state = otherFSM._states[state_name]
+ except:
+ return False
+ # If two states with the same name exist, then we want to make sure
+ # they are also identical
+ if not state == other_state:
+ return False
+
+ # If we made it here, then all the states in this FSM could be matched to an
+ # identical state in the other FSM.
+ return True
+ if len(self._states) != len(otherFSM._states):
+ return False
+
+ # For each state, try to find a corresponding state in the other FSM
+ for state_name in self._states.keys():
+ state = self._states[state_name]
+ other_state = None
+ try:
+ # Attempt to use this key in the other FSM. If it's not present
+ # the dictionary will throw an exception and we'll know we have
+ # at least one different state in the other FSM
+ other_state = otherFSM._states[state_name]
+ except:
+ return False
+ # If two states with the same name exist, then we want to make sure
+ # they are also identical
+ if not state == other_state:
+ return False
+
+ # If we made it here, then all the states in this FSM could be matched to an
+ # identical state in the other FSM.
+ return True
diff --git a/tutorius/creator.py b/tutorius/creator.py
index fabe879..c477056 100644
--- a/tutorius/creator.py
+++ b/tutorius/creator.py
@@ -22,16 +22,19 @@ the activity itself.
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
import gtk.gdk
+import gtk.glade
import gobject
from gettext import gettext as T
-from sugar.graphics.toolbutton import ToolButton
+import os
+from sugar.graphics import icon
+import copy
-from sugar.tutorius import overlayer, gtkutils, actions, bundler, properties, addon
-from sugar.tutorius import filters
-from sugar.tutorius.services import ObjectStore
-from sugar.tutorius.linear_creator import LinearCreator
-from sugar.tutorius.core import Tutorial
+from . import overlayer, gtkutils, actions, vault, properties, addon
+from . import filters
+from .services import ObjectStore
+from .core import Tutorial, FiniteStateMachine, State
+from . import viewer
class Creator(object):
"""
@@ -47,80 +50,162 @@ class Creator(object):
"""
self._activity = activity
if not tutorial:
- self._tutorial = LinearCreator()
+ self._tutorial = FiniteStateMachine('Untitled')
+ self._state = State(name='INIT')
+ self._tutorial.add_state(self._state)
+ self._state_counter = 1
else:
self._tutorial = tutorial
+ # TODO load existing tutorial; unused yet
self._action_panel = None
self._current_filter = None
self._intro_mask = None
self._intro_handle = None
- self._state_bubble = overlayer.TextBubble(self._tutorial.state_name)
allocation = self._activity.get_allocation()
self._width = allocation.width
self._height = allocation.height
self._selected_widget = None
self._eventmenu = None
+ self.tuto = None
+ self._guid = None
self._hlmask = overlayer.Rectangle(None, (1.0, 0.0, 0.0, 0.5))
self._activity._overlayer.put(self._hlmask, 0, 0)
- self._activity._overlayer.put(self._state_bubble,
- self._width/2-self._state_bubble.allocation.width/2, 0)
- self._state_bubble.show()
-
dlg_width = 300
dlg_height = 70
sw = gtk.gdk.screen_width()
sh = gtk.gdk.screen_height()
- self._tooldialog = gtk.Window()
- self._tooldialog.set_title("Tutorius tools")
- self._tooldialog.set_transient_for(self._activity)
- self._tooldialog.set_decorated(True)
- self._tooldialog.set_resizable(False)
- self._tooldialog.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_UTILITY)
- self._tooldialog.set_destroy_with_parent(True)
- self._tooldialog.set_deletable(False)
- self._tooldialog.set_size_request(dlg_width, dlg_height)
-
- toolbar = gtk.Toolbar()
- for tool in addon.list_addons():
- meta = addon.get_addon_meta(tool)
- toolitem = ToolButton(meta['icon'])
- toolitem.set_tooltip(meta['display_name'])
- toolitem.connect("clicked", self._add_action_cb, tool)
- toolbar.insert(toolitem, -1)
- toolitem = ToolButton("go-next")
- toolitem.connect("clicked", self._add_step_cb)
- toolitem.set_tooltip("Add Step")
- toolbar.insert(toolitem, -1)
- toolitem = ToolButton("stop")
- toolitem.connect("clicked", self._cleanup_cb)
- toolitem.set_tooltip("End Tutorial")
- toolbar.insert(toolitem, -1)
- self._tooldialog.add(toolbar)
- self._tooldialog.show_all()
- # simpoir: I suspect the realized widget is a tiny bit larger than
- # it should be, thus the -10.
- self._tooldialog.move(sw-10-dlg_width, sh-dlg_height)
-
- self._propedit = EditToolBox(self._activity)
-
- def _evfilt_cb(self, menuitem, event_name, *args):
+
+ self._propedit = ToolBox(self._activity)
+ self._propedit.tree.signal_autoconnect({
+ 'on_quit_clicked': self._cleanup_cb,
+ 'on_save_clicked': self.save,
+ 'on_action_activate': self._add_action_cb,
+ 'on_event_activate': self._add_event_cb,
+ })
+ self._propedit.window.move(
+ gtk.gdk.screen_width()-self._propedit.window.get_allocation().width,
+ 100)
+
+
+ self._overview = viewer.Viewer(self._tutorial, self)
+ self._overview.win.set_transient_for(self._activity)
+
+ self._overview.win.move(0, gtk.gdk.screen_height()- \
+ self._overview.win.get_allocation().height)
+
+ self._transitions = dict()
+
+ def _update_next_state(self, state, event, next_state):
+ self._transitions[event] = next_state
+
+ evts = state.get_event_filter_list()
+ state.clear_event_filters()
+ for evt, next_state in evts:
+ state.add_event_filter(evt, self._transitions[evt])
+
+ def delete_action(self, action):
+ """
+ Removes the first instance of specified action from the tutorial.
+
+ @param action: the action object to remove from the tutorial
+ @returns: True if successful, otherwise False.
+ """
+ state = self._tutorial.get_state_by_name("INIT")
+
+ while True:
+ state_actions = state.get_action_list()
+ for fsm_action in state_actions:
+ if fsm_action is action:
+ state.clear_actions()
+ if state is self._state:
+ fsm_action.exit_editmode()
+ state_actions.remove(fsm_action)
+ self.set_insertion_point(state.name)
+ for keep_action in state_actions:
+ state.add_action(keep_action)
+ return True
+
+ ev_list = state.get_event_filter_list()
+ if ev_list:
+ state = self._tutorial.get_state_by_name(ev_list[0][1])
+ continue
+
+ return False
+
+ def delete_state(self):
+ """
+ Remove current state.
+ Limitation: The last state cannot be removed, as it doesn't have
+ any transitions to remove anyway.
+
+ @returns: True if successful, otherwise False.
+ """
+ if not self._state.get_event_filter_list():
+ # last state cannot be removed
+ return False
+
+ state = self._tutorial.get_state_by_name("INIT")
+ ev_list = state.get_event_filter_list()
+ if state is self._state:
+ next_state = self._tutorial.get_state_by_name(ev_list[0][1])
+ self.set_insertion_point(next_state.name)
+ self._tutorial.remove_state(state.name)
+ self._tutorial.remove_state(next_state.name)
+ next_state.name = "INIT"
+ self._tutorial.add_state(next_state)
+ return True
+
+ # loop to repair links from deleted state
+ while ev_list:
+ next_state = self._tutorial.get_state_by_name(ev_list[0][1])
+ if next_state is self._state:
+ # the tutorial will flush the event filters. We'll need to
+ # clear and re-add them.
+ self._tutorial.remove_state(self._state.name)
+ state.clear_event_filters()
+ self._update_next_state(state, ev_list[0][0], next_state.get_event_filter_list()[0][1])
+ for ev, next_state in ev_list:
+ state.add_event_filter(ev, next_state)
+
+ self.set_insertion_point(ev_list[0][1])
+ return True
+
+ state = next_state
+ ev_list = state.get_event_filter_list()
+ return False
+
+ def get_insertion_point(self):
+ return self._state.name
+
+ def set_insertion_point(self, state_name):
+ for action in self._state.get_action_list():
+ action.exit_editmode()
+ self._state = self._tutorial.get_state_by_name(state_name)
+ self._overview.win.queue_draw()
+ state_actions = self._state.get_action_list()
+ for action in state_actions:
+ action.enter_editmode()
+ action._drag._eventbox.connect_after(
+ "button-release-event", self._action_refresh_cb, action)
+
+ if state_actions:
+ self._propedit.action = state_actions[0]
+ else:
+ self._propedit.action = None
+
+
+ def _evfilt_cb(self, menuitem, event):
"""
This will get called once the user has selected a menu item from the
event filter popup menu. This should add the correct event filter
to the FSM and increment states.
"""
- self.introspecting = False
- eventfilter = addon.create('GtkWidgetEventFilter',
- object_id=self._selected_widget,
- event_name=event_name)
# undo actions so they don't persist through step editing
- for action in self._tutorial.current_actions:
+ for action in self._state.get_action_list():
action.exit_editmode()
- self._tutorial.event(eventfilter)
- self._state_bubble.label = self._tutorial.state_name
self._hlmask.covered = None
self._propedit.action = None
self._activity.queue_draw()
@@ -159,67 +244,70 @@ class Creator(object):
self._eventmenu.popup(None, None, None, evt.button, evt.time)
self._activity.queue_draw()
- def set_intropecting(self, value):
- """
- Set whether creator is in UI introspection mode. Setting this will
- connect necessary handlers.
- @param value True to setup introspection handlers.
- """
- if bool(value) ^ bool(self._intro_mask):
- if value:
- self._intro_mask = overlayer.Mask(catch_events=True)
- self._intro_handle = self._intro_mask.connect_after(
- "button-press-event", self._intro_cb)
- self._activity._overlayer.put(self._intro_mask, 0, 0)
- else:
- self._intro_mask.catch_events = False
- self._intro_mask.disconnect(self._intro_handle)
- self._intro_handle = None
- self._activity._overlayer.remove(self._intro_mask)
- self._intro_mask = None
-
- def get_introspecting(self):
- """
- Whether creator is in UI introspection (catch all event) mode.
- @return True if introspection handlers are connected, or False if not.
- """
- return bool(self._intro_mask)
-
- introspecting = property(fset=set_intropecting, fget=get_introspecting)
-
- def _add_action_cb(self, widget, actiontype):
+ def _add_action_cb(self, widget, path):
"""Callback for the action creation toolbar tool"""
- action = addon.create(actiontype)
- if isinstance(action, actions.Action):
- action.enter_editmode()
- self._tutorial.action(action)
- # FIXME: replace following with event catching
- action._drag._eventbox.connect_after(
- "button-release-event", self._action_refresh_cb, action)
+ action_type = self._propedit.actions_list[path][ToolBox.ICON_NAME]
+ action = addon.create(action_type)
+ action.enter_editmode()
+ self._state.add_action(action)
+ # FIXME: replace following with event catching
+ action._drag._eventbox.connect_after(
+ "button-release-event", self._action_refresh_cb, action)
+ self._overview.win.queue_draw()
+
+ def _add_event_cb(self, widget, path):
+ """Callback for the event creation toolbar tool"""
+ event_type = self._propedit.events_list[path][ToolBox.ICON_NAME]
+ event = addon.create(event_type)
+ addonname = type(event).__name__
+ meta = addon.get_addon_meta(addonname)
+ for propname in meta['mandatory_props']:
+ prop = getattr(type(event), propname)
+ if isinstance(prop, properties.TUAMProperty):
+ selector = WidgetSelector(self._activity)
+ setattr(event, propname, selector.select())
+ elif isinstance(prop, properties.TEventType):
+ try:
+ dlg = SignalInputDialog(self._activity,
+ text="Mandatory property",
+ field=propname,
+ addr=event.object_id)
+ setattr(event, propname, dlg.pop())
+ except AttributeError:
+ pass
+ elif isinstance(prop, properties.TStringProperty):
+ dlg = TextInputDialog(self._activity,
+ text="Mandatory property",
+ field=propname)
+ setattr(event, propname, dlg.pop())
+ else:
+ raise NotImplementedError()
+
+ event_filters = self._state.get_event_filter_list()
+ if event_filters:
+ # linearize tutorial by inserting state
+ new_state = State(name=str(self._state_counter))
+ self._state_counter += 1
+ self._state.clear_event_filters()
+ for evt_filt, next_state in event_filters:
+ new_state.add_event_filter(evt_filt, next_state)
+ self._update_next_state(self._state, event, new_state.name)
+ next_state = new_state.name
+ # blocks are shifted, full redraw is necessary
+ self._overview.win.queue_draw()
else:
- addonname = type(action).__name__
- meta = addon.get_addon_meta(addonname)
- had_introspect = False
- for propname in meta['mandatory_props']:
- prop = getattr(type(action), propname)
- if isinstance(prop, properties.TUAMProperty):
- had_introspect = True
- self.introspecting = True
- elif isinstance(prop, properties.TStringProperty):
- dlg = TextInputDialog(text="Mandatory property",
- field=propname)
- setattr(action, propname, dlg.pop())
- elif isinstance(prop, properties.TIntProperty):
- dlg = TextInputDialog(text="Mandatory property",
- field=propname)
- setattr(action, propname, int(dlg.pop()))
- else:
- raise NotImplementedError()
-
- # FIXME: hack to reuse previous introspection code
- if not had_introspect:
- self._tutorial.event(action)
+ # append empty state only if edit inserting at end of linearized
+ # tutorial.
+ self._update_next_state(self._state, event, str(self._state_counter))
+ next_state = str(self._state_counter)
+ new_state = State(name=str(self._state_counter))
+ self._state_counter += 1
+
+ self._state.add_event_filter(event, next_state)
+ self._tutorial.add_state(new_state)
+ self._overview.win.queue_draw()
+ self.set_insertion_point(new_state.name)
def _action_refresh_cb(self, widget, evt, action):
"""
@@ -234,44 +322,54 @@ class Creator(object):
"button-release-event", self._action_refresh_cb, action)
self._propedit.action = action
- def _add_step_cb(self, widget):
- """Callback for the "add step" tool"""
- self.introspecting = True
+ self._overview.win.queue_draw()
def _cleanup_cb(self, *args):
"""
Quit editing and cleanup interface artifacts.
"""
- self.introspecting = False
- eventfilter = filters.EventFilter()
# undo actions so they don't persist through step editing
- for action in self._tutorial.current_actions:
+ for action in self._state.get_action_list():
action.exit_editmode()
- self._tutorial.event(eventfilter)
- dlg = TextInputDialog(text=T("Enter a tutorial title."),
- field=T("Title"))
- tutorialName = ""
- while not tutorialName: tutorialName = dlg.pop()
- dlg.destroy()
-
- # prepare tutorial for serialization
- tuto = Tutorial(tutorialName, self._tutorial.fsm)
- bundle = bundler.TutorialBundler()
- bundle.write_metadata_file(tuto)
- bundle.write_fsm(self._tutorial.fsm)
+ dialog = gtk.MessageDialog(
+ parent=self._activity,
+ flags=gtk.DIALOG_MODAL,
+ type=gtk.MESSAGE_QUESTION,
+ buttons=gtk.BUTTONS_YES_NO,
+ message_format=T('Do you want to save before stopping edition?'))
+ do_save = dialog.run()
+ dialog.destroy()
+ if do_save == gtk.RESPONSE_YES:
+ self.save()
# remove UI remains
self._hlmask.covered = None
self._activity._overlayer.remove(self._hlmask)
- self._activity._overlayer.remove(self._state_bubble)
self._hlmask.destroy()
self._hlmask = None
- self._tooldialog.destroy()
self._propedit.destroy()
+ self._overview.destroy()
self._activity.queue_draw()
del self._activity._creator
+ def save(self, widget=None):
+ if not self.tuto:
+ dlg = TextInputDialog(self._activity,
+ text=T("Enter a tutorial title."),
+ field=T("Title"))
+ tutorialName = ""
+ while not tutorialName: tutorialName = dlg.pop()
+ dlg.destroy()
+
+ # prepare tutorial for serialization
+ self.tuto = Tutorial(tutorialName, self._tutorial)
+ bundle = vault.TutorialBundler(self._guid)
+ self._guid = bundle.Guid
+ bundle.write_metadata_file(self.tuto)
+ bundle.write_fsm(self._tutorial)
+
+
def launch(*args, **kwargs):
"""
Launch and attach a creator to the currently running activity.
@@ -281,46 +379,59 @@ class Creator(object):
activity._creator = Creator(activity)
launch = staticmethod(launch)
-class EditToolBox(gtk.Window):
- """Helper toolbox class for managing action properties"""
- def __init__(self, parent, action=None):
- """
- Create the property edition toolbox and display it.
+class ToolBox(object):
+ ICON_LABEL = 0
+ ICON_IMAGE = 1
+ ICON_NAME = 2
+ ICON_TIP = 3
+ def __init__(self, parent):
+ super(ToolBox, self).__init__()
+ self.__parent = parent
+ sugar_prefix = os.getenv("SUGAR_PREFIX",default="/usr")
+ glade_file = os.path.join(sugar_prefix, 'share', 'tutorius',
+ 'ui', 'creator.glade')
+ self.tree = gtk.glade.XML(glade_file)
+ self.window = self.tree.get_widget('mainwindow')
+ self._propbox = self.tree.get_widget('propbox')
+
+ self.window.set_transient_for(parent)
- @param parent the parent window of this toolbox, usually an activity
- @param action the action to introspect/edit
- """
- gtk.Window.__init__(self)
self._action = None
- self.__parent = parent # private avoid gtk clash
-
- self.set_title("Action Properties")
- self.set_transient_for(parent)
- self.set_decorated(True)
- self.set_resizable(False)
- self.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_UTILITY)
- self.set_destroy_with_parent(True)
- self.set_deletable(False)
- self.set_size_request(200, 400)
-
- self._vbox = gtk.VBox()
- self.add(self._vbox)
- propwin = gtk.ScrolledWindow()
- propwin.props.hscrollbar_policy = gtk.POLICY_AUTOMATIC
- propwin.props.vscrollbar_policy = gtk.POLICY_AUTOMATIC
- self._vbox.pack_start(propwin)
- self._propbox = gtk.VBox(spacing=10)
- propwin.add(self._propbox)
-
- self.action = action
-
- sw = gtk.gdk.screen_width()
- sh = gtk.gdk.screen_height()
-
- self.show_all()
- self.move(sw-10-200, (sh-400)/2)
-
- def refresh(self):
+ self.actions_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str)
+ self.actions_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING)
+ self.events_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str)
+ self.events_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING)
+
+ for toolname in addon.list_addons():
+ meta = addon.get_addon_meta(toolname)
+ iconfile = gtk.Image()
+ iconfile.set_from_file(icon.get_icon_file_name(meta['icon']))
+ img = iconfile.get_pixbuf()
+ label = format_multiline(meta['display_name'])
+
+ if meta['type'] == addon.TYPE_ACTION:
+ self.actions_list.append((label, img, toolname, meta['display_name']))
+ else:
+ self.events_list.append((label, img, toolname, meta['display_name']))
+
+ iconview_action = self.tree.get_widget('iconview1')
+ iconview_action.set_model(self.actions_list)
+ iconview_action.set_text_column(self.ICON_LABEL)
+ iconview_action.set_pixbuf_column(self.ICON_IMAGE)
+ iconview_action.set_tooltip_column(self.ICON_TIP)
+ iconview_event = self.tree.get_widget('iconview2')
+ iconview_event.set_model(self.events_list)
+ iconview_event.set_text_column(self.ICON_LABEL)
+ iconview_event.set_pixbuf_column(self.ICON_IMAGE)
+ iconview_event.set_tooltip_column(self.ICON_TIP)
+
+ self.window.show()
+
+ def destroy(self):
+ """ clean and free the toolbox """
+ self.window.destroy()
+
+ def refresh_properties(self):
"""Refresh property values from the selected action."""
if self._action is None:
return
@@ -333,6 +444,9 @@ class EditToolBox(gtk.Window):
if isinstance(prop, properties.TStringProperty):
propwdg = row.get_children()[1]
propwdg.get_buffer().set_text(propval)
+ elif isinstance(prop, properties.TUAMProperty):
+ propwdg = row.get_children()[1]
+ propwdg.set_label(propval)
elif isinstance(prop, properties.TIntProperty):
propwdg = row.get_children()[1]
propwdg.set_value(propval)
@@ -348,12 +462,10 @@ class EditToolBox(gtk.Window):
def set_action(self, action):
"""Setter for the action property."""
if self._action is action:
- self.refresh()
+ self.refresh_properties()
return
- parent = self._propbox.get_parent()
- parent.remove(self._propbox)
- self._propbox = gtk.VBox(spacing=10)
- parent.add(self._propbox)
+ for old_prop in self._propbox.get_children():
+ self._propbox.remove(old_prop)
self._action = action
if action is None:
@@ -368,6 +480,10 @@ class EditToolBox(gtk.Window):
propwdg.get_buffer().set_text(propval)
propwdg.connect_after("focus-out-event", \
self._str_prop_changed, action, propname)
+ elif isinstance(prop, properties.TUAMProperty):
+ propwdg = gtk.Button(propval)
+ propwdg.connect_after("clicked", \
+ self._uam_prop_changed, action, propname)
elif isinstance(prop, properties.TIntProperty):
adjustment = gtk.Adjustment(value=propval,
lower=prop.lower_limit.limit,
@@ -388,8 +504,8 @@ class EditToolBox(gtk.Window):
propwdg.set_text(str(propval))
row.pack_end(propwdg)
self._propbox.pack_start(row, expand=False)
- self._vbox.show_all()
- self.refresh()
+ self._propbox.show_all()
+ self.refresh_properties()
def get_action(self):
"""Getter for the action property"""
@@ -399,12 +515,18 @@ class EditToolBox(gtk.Window):
def _list_prop_changed(self, widget, evt, action, propname, idx):
try:
+ #Save props as tuples so that they can be hashed
attr = list(getattr(action, propname))
attr[idx] = int(widget.get_text())
- setattr(action, propname, attr)
+ setattr(action, propname, tuple(attr))
except ValueError:
widget.set_text(str(getattr(action, propname)[idx]))
self.__parent._creator._action_refresh_cb(None, None, action)
+ def _uam_prop_changed(self, widget, action, propname):
+ selector = WidgetSelector(self.__parent)
+ selection = selector.select()
+ setattr(action, propname, selection)
+ self.__parent._creator._action_refresh_cb(None, None, action)
def _str_prop_changed(self, widget, evt, action, propname):
buf = widget.get_buffer()
setattr(action, propname, buf.get_text(buf.get_start_iter(), buf.get_end_iter()))
@@ -413,9 +535,143 @@ class EditToolBox(gtk.Window):
setattr(action, propname, widget.get_value_as_int())
self.__parent._creator._action_refresh_cb(None, None, action)
+
+class WidgetSelector(object):
+ """
+ Allow selecting a widget from within a window without interrupting the
+ flow of the current call.
+
+ The selector will run on the specified window until either a widget
+ is selected or abort() gets called.
+ """
+ def __init__(self, window):
+ super(WidgetSelector, self).__init__()
+ self.window = window
+ self._intro_mask = None
+ self._intro_handle = None
+ self._select_handle = None
+ self._prelight = None
+
+ def select(self):
+ """
+ Starts selecting a widget, by grabbing control of the mouse and
+ highlighting hovered widgets until one is clicked.
+ @returns: a widget address or None
+ """
+ if not self._intro_mask:
+ self._prelight = None
+ self._intro_mask = overlayer.Mask(catch_events=True)
+ self._select_handle = self._intro_mask.connect_after(
+ "button-press-event", self._end_introspect)
+ self._intro_handle = self._intro_mask.connect_after(
+ "motion-notify-event", self._intro_cb)
+ self.window._overlayer.put(self._intro_mask, 0, 0)
+ self.window._overlayer.queue_draw()
+
+ while bool(self._intro_mask) and not gtk.main_iteration():
+ pass
+
+ return gtkutils.raddr_lookup(self._prelight)
+
+ def _end_introspect(self, widget, evt):
+ if evt.type == gtk.gdk.BUTTON_PRESS and self._prelight:
+ self._intro_mask.catch_events = False
+ self._intro_mask.disconnect(self._intro_handle)
+ self._intro_handle = None
+ self._intro_mask.disconnect(self._select_handle)
+ self._select_handle = None
+ self.window._overlayer.remove(self._intro_mask)
+ self._intro_mask = None
+ # for some reason, gtk may not redraw after this unless told to.
+ self.window.queue_draw()
+
+ def _intro_cb(self, widget, evt):
+ """
+ Callback for capture of widget events, when in introspect mode.
+ """
+ # widget has focus, let's hilight it
+ win = gtk.gdk.display_get_default().get_window_at_pointer()
+ if not win:
+ return
+ click_wdg = win[0].get_user_data()
+ if not click_wdg.is_ancestor(self.window._overlayer):
+ # as popups are not (yet) supported, it would break
+ # badly if we were to play with a widget not in the
+ # hierarchy.
+ return
+ for hole in self._intro_mask.pass_thru:
+ self._intro_mask.mask(hole)
+ self._intro_mask.unmask(click_wdg)
+ self._prelight = click_wdg
+
+ self.window.queue_draw()
+
+ def abort(self):
+ """
+ Ends the selection. The control will return to the select() caller
+ with a return value of None, as selection was aborted.
+ """
+ self._intro_mask.catch_events = False
+ self._intro_mask.disconnect(self._intro_handle)
+ self._intro_handle = None
+ self._intro_mask.disconnect(self._select_handle)
+ self._select_handle = None
+ self.window._overlayer.remove(self._intro_mask)
+ self._intro_mask = None
+ self._prelight = None
+
+class SignalInputDialog(gtk.MessageDialog):
+ def __init__(self, parent, text, field, addr):
+ """
+ Create a gtk signal selection dialog.
+
+ @param parent: the parent window this dialog should stay over.
+ @param text: the title of the dialog.
+ @param field: the field description of the dialog.
+ @param addr: the widget address from which to fetch signal list.
+ """
+ gtk.MessageDialog.__init__(self, parent,
+ gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
+ gtk.MESSAGE_QUESTION,
+ gtk.BUTTONS_OK,
+ None)
+ self.set_markup(text)
+ self.model = gtk.ListStore(str)
+ widget = gtkutils.find_widget(parent, addr)
+ for signal_name in gobject.signal_list_names(widget):
+ self.model.append(row=(signal_name,))
+ self.entry = gtk.ComboBox(self.model)
+ cell = gtk.CellRendererText()
+ self.entry.pack_start(cell)
+ self.entry.add_attribute(cell, 'text', 0)
+ hbox = gtk.HBox()
+ lbl = gtk.Label(field)
+ hbox.pack_start(lbl, False)
+ hbox.pack_end(self.entry)
+ self.vbox.pack_end(hbox, True, True)
+ self.show_all()
+
+ def pop(self):
+ """
+ Show the dialog. It will run in it's own loop and return control
+ to the caller when a signal has been selected.
+
+ @returns: a signal name or None if no signal was selected
+ """
+ self.run()
+ self.hide()
+ iter = self.entry.get_active_iter()
+ if iter:
+ text = self.model.get_value(iter, 0)
+ return text
+ return None
+
+ def _dialog_done_cb(self, entry, response):
+ self.response(response)
+
class TextInputDialog(gtk.MessageDialog):
- def __init__(self, text, field):
- gtk.MessageDialog.__init__(self, None,
+ def __init__(self, parent, text, field):
+ gtk.MessageDialog.__init__(self, parent,
gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
gtk.MESSAGE_QUESTION,
gtk.BUTTONS_OK,
@@ -439,4 +695,39 @@ class TextInputDialog(gtk.MessageDialog):
def _dialog_done_cb(self, entry, response):
self.response(response)
+# The purpose of this function is to reformat text, as current IconView
+# implentation does not insert carriage returns on long lines.
+# To preserve layout, this call reformat text to fit in small space under an
+# icon.
+def format_multiline(text, length=10, lines=3, line_separator='\n'):
+ """
+ Reformat a text to fit in a small space.
+
+ @param length: maximum char per line
+ @param lines: maximum number of lines
+ """
+ words = text.split(' ')
+ line = list()
+ return_val = []
+ linelen = 0
+
+ for word in words:
+ t_len = linelen+len(word)
+ if t_len < length:
+ line.append(word)
+ linelen = t_len+1 # count space
+ else:
+ if len(return_val)+1 < lines:
+ return_val.append(' '.join(line))
+ line = list()
+ linelen = 0
+ line.append(word)
+ else:
+ return_val.append(' '.join(line+['...']))
+ return line_separator.join(return_val)
+
+ return_val.append(' '.join(line))
+ return line_separator.join(return_val)
+
+
# vim:set ts=4 sts=4 sw=4 et:
diff --git a/tutorius/dbustools.py b/tutorius/dbustools.py
index 1b685d7..5d70d7b 100644
--- a/tutorius/dbustools.py
+++ b/tutorius/dbustools.py
@@ -25,6 +25,7 @@ def remote_call(callable, args, return_cb=None, error_cb=None, block=False):
except Exception, e:
#Use the specified error handler even for blocking calls
errhandler_cb(e)
+ return
#Return value signature might be :
if ret_val is None:
diff --git a/tutorius/editor.py b/tutorius/editor.py
index 42cc718..9d2effe 100644
--- a/tutorius/editor.py
+++ b/tutorius/editor.py
@@ -24,7 +24,7 @@ import gobject
from gettext import gettext as _
-from sugar.tutorius.gtkutils import register_signals_numbered, get_children
+from .gtkutils import register_signals_numbered, get_children
class WidgetIdentifier(gtk.Window):
"""
diff --git a/tutorius/editor_interpreter.py b/tutorius/editor_interpreter.py
new file mode 100644
index 0000000..d559266
--- /dev/null
+++ b/tutorius/editor_interpreter.py
@@ -0,0 +1,105 @@
+# Copyright (C) 2009, Tutorius.org
+# Greatly influenced by sugar/activity/namingalert.py
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+""" Tutorial Editor Interpreter Module
+"""
+
+import gtk
+import pango
+from sugar.tutorius.ipython_view import *
+
+from gettext import gettext as _
+
+class EditorInterpreter(gtk.Window):
+ """
+ Interpreter that will be shown to the user
+ """
+ __gtype_name__ = 'TutoriusEditorInterpreter'
+
+ def __init__(self, activity=None):
+ gtk.Window.__init__(self)
+
+ self._activity = activity
+
+ # Set basic properties of window
+ self.set_decorated(False)
+ self.set_resizable(False)
+ self.set_modal(False)
+
+ # Connect to realize signal from ?
+ self.connect('realize', self.__realize_cb)
+
+ # Use an expander widget to allow minimizing
+ self._expander = gtk.Expander(_("Editor Interpreter"))
+ self._expander.set_expanded(True)
+ self.add(self._expander)
+ self._expander.connect("notify::expanded", self.__expander_cb)
+
+
+ # Use the IPython widget to embed
+ self.interpreter = IPythonView()
+ self.interpreter.set_wrap_mode(gtk.WRAP_CHAR)
+
+ # Expose the activity object in the interpreter
+ self.interpreter.updateNamespace({'activity':self._activity})
+
+ # Use a scroll window to permit scrolling of the interpreter prompt
+ swd = gtk.ScrolledWindow()
+ swd.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
+ swd.add(self.interpreter)
+ self.interpreter.show()
+
+ # Notify GTK that expander is ready
+ self._expander.add(swd)
+ self._expander.show()
+
+ # Notify GTK that the scrolling window is ready
+ swd.show()
+
+ def __expander_cb(self, *args):
+ """Callback for the window expander toggling"""
+ if self._expander.get_expanded():
+ self.__move_expanded()
+ else:
+ self.__move_collapsed()
+
+ def __move_expanded(self):
+ """Move the window to it's expanded position"""
+ swidth = gtk.gdk.screen_width()
+ sheight = gtk.gdk.screen_height()
+ # leave room for the scrollbar at the right
+ width = swidth - 20
+ height = 200
+
+ self.set_size_request(width, height)
+ # Put at the bottom of the screen
+ self.move(0, sheight-height)
+
+ def __move_collapsed(self):
+ """Move the window to it's collapsed position"""
+ width = 150
+ height = 40
+ swidth = gtk.gdk.screen_width()
+ sheight = gtk.gdk.screen_height()
+
+ self.set_size_request(width, height)
+ self.move(((swidth-width)/2)-150, sheight-height)
+
+ def __realize_cb(self, widget):
+ """Callback for realize"""
+ self.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG)
+ self.window.set_accept_focus(True)
+ self.__move_expanded()
diff --git a/tutorius/engine.py b/tutorius/engine.py
index dda9f3f..e77a018 100644
--- a/tutorius/engine.py
+++ b/tutorius/engine.py
@@ -1,10 +1,10 @@
import logging
import dbus.mainloop.glib
from jarabe.model import shell
-
-from sugar.tutorius.bundler import TutorialStore
from sugar.bundle.activitybundle import ActivityBundle
+from .vault import Vault
+
class Engine:
"""
Driver for the execution of tutorials
@@ -25,11 +25,9 @@ class Engine:
self._tutorial.detach()
self._tutorial = None
- store = TutorialStore()
-
#Get the active activity from the shell
activity = self._shell.get_active_activity()
- self._tutorial = store.load_tutorial(tutorialID, bundle_path=activity.get_bundle_path())
+ self._tutorial = Vault.loadTutorial(tutorialID)
#TProbes automatically use the bundle id, available from the ActivityBundle
bundle = ActivityBundle(activity.get_bundle_path())
diff --git a/tutorius/events.py b/tutorius/events.py
new file mode 100644
index 0000000..bf0a8b9
--- /dev/null
+++ b/tutorius/events.py
@@ -0,0 +1,36 @@
+from sugar.tutorius.properties import *
+
+class Event(TPropContainer):
+ source = TUAMProperty()
+ type = TStringProperty("clicked")
+
+ def __init__(self):
+ TPropContainer.__init__(self)
+
+
+ # Providing the hash methods necessary to use events as key
+ # in a dictionary, if new properties are added we should
+ # take them into account here
+ def __hash__(self):
+ return hash(str(self.source) + str(self.type))
+
+ def __eq__(self, e2):
+ return self.source == e2.source and self.type == e2.type
+
+
+ # Adding methods for pickling and unpickling an object with
+ # properties
+ def __getstate__(self):
+ return self._props.copy()
+
+ def __setstate__(self, dict):
+ self._props.update(dict)
+
+
+# Nothing more needs to be added, the additional
+# information is in the object type
+class ClickedEvent(Event):
+ def __init__(self):
+ Event.__init__(self)
+ self.type = "clicked"
+
diff --git a/tutorius/filters.py b/tutorius/filters.py
index 44621d5..38cf86b 100644
--- a/tutorius/filters.py
+++ b/tutorius/filters.py
@@ -18,7 +18,7 @@
import logging
logger = logging.getLogger("filters")
-from sugar.tutorius import properties
+from . import properties
class EventFilter(properties.TPropContainer):
diff --git a/tutorius/ipython_view.py b/tutorius/ipython_view.py
new file mode 100644
index 0000000..c4294d0
--- /dev/null
+++ b/tutorius/ipython_view.py
@@ -0,0 +1,301 @@
+"""
+Backend to the console plugin.
+
+@author: Eitan Isaacson
+@organization: IBM Corporation
+@copyright: Copyright (c) 2007 IBM Corporation
+@license: BSD
+
+All rights reserved. This program and the accompanying materials are made
+available under the terms of the BSD which accompanies this distribution, and
+is available at U{http://www.opensource.org/licenses/bsd-license.php}
+"""
+# this file is a modified version of source code from the Accerciser project
+# http://live.gnome.org/accerciser
+
+import gtk
+import re
+import sys
+import os
+import pango
+from StringIO import StringIO
+
+try:
+ import IPython
+except Exception,e:
+ raise "Error importing IPython (%s)" % str(e)
+
+ansi_colors = {'0;30': 'Black',
+ '0;31': 'Red',
+ '0;32': 'Green',
+ '0;33': 'Brown',
+ '0;34': 'Blue',
+ '0;35': 'Purple',
+ '0;36': 'Cyan',
+ '0;37': 'LightGray',
+ '1;30': 'DarkGray',
+ '1;31': 'DarkRed',
+ '1;32': 'SeaGreen',
+ '1;33': 'Yellow',
+ '1;34': 'LightBlue',
+ '1;35': 'MediumPurple',
+ '1;36': 'LightCyan',
+ '1;37': 'White'}
+
+class IterableIPShell:
+ def __init__(self,argv=None,user_ns=None,user_global_ns=None,
+ cin=None, cout=None,cerr=None, input_func=None):
+ if input_func:
+ IPython.iplib.raw_input_original = input_func
+ if cin:
+ IPython.Shell.Term.cin = cin
+ if cout:
+ IPython.Shell.Term.cout = cout
+ if cerr:
+ IPython.Shell.Term.cerr = cerr
+
+ if argv is None:
+ argv=[]
+
+ # This is to get rid of the blockage that occurs during
+ # IPython.Shell.InteractiveShell.user_setup()
+ IPython.iplib.raw_input = lambda x: None
+
+ self.term = IPython.genutils.IOTerm(cin=cin, cout=cout, cerr=cerr)
+ os.environ['TERM'] = 'dumb'
+ excepthook = sys.excepthook
+ self.IP = IPython.Shell.make_IPython(argv,user_ns=user_ns,
+ user_global_ns=user_global_ns,
+ embedded=True,
+ shell_class=IPython.Shell.InteractiveShell)
+ self.IP.system = lambda cmd: self.shell(self.IP.var_expand(cmd),
+ header='IPython system call: ',
+ verbose=self.IP.rc.system_verbose)
+ sys.excepthook = excepthook
+ self.iter_more = 0
+ self.history_level = 0
+ self.complete_sep = re.compile('[\s\{\}\[\]\(\)]')
+
+ def execute(self):
+ self.history_level = 0
+ orig_stdout = sys.stdout
+ sys.stdout = IPython.Shell.Term.cout
+ try:
+ line = self.IP.raw_input(None, self.iter_more)
+ if self.IP.autoindent:
+ self.IP.readline_startup_hook(None)
+ except KeyboardInterrupt:
+ self.IP.write('\nKeyboardInterrupt\n')
+ self.IP.resetbuffer()
+ # keep cache in sync with the prompt counter:
+ self.IP.outputcache.prompt_count -= 1
+
+ if self.IP.autoindent:
+ self.IP.indent_current_nsp = 0
+ self.iter_more = 0
+ except:
+ self.IP.showtraceback()
+ else:
+ self.iter_more = self.IP.push(line)
+ if (self.IP.SyntaxTB.last_syntax_error and
+ self.IP.rc.autoedit_syntax):
+ self.IP.edit_syntax_error()
+ if self.iter_more:
+ self.prompt = str(self.IP.outputcache.prompt2).strip()
+ if self.IP.autoindent:
+ self.IP.readline_startup_hook(self.IP.pre_readline)
+ else:
+ self.prompt = str(self.IP.outputcache.prompt1).strip()
+ sys.stdout = orig_stdout
+
+ def historyBack(self):
+ self.history_level -= 1
+ return self._getHistory()
+
+ def historyForward(self):
+ self.history_level += 1
+ return self._getHistory()
+
+ def _getHistory(self):
+ try:
+ rv = self.IP.user_ns['In'][self.history_level].strip('\n')
+ except IndexError:
+ self.history_level = 0
+ rv = ''
+ return rv
+
+ def updateNamespace(self, ns_dict):
+ self.IP.user_ns.update(ns_dict)
+
+ def complete(self, line):
+ split_line = self.complete_sep.split(line)
+ possibilities = self.IP.complete(split_line[-1])
+ if possibilities:
+ common_prefix = reduce(self._commonPrefix, possibilities)
+ completed = line[:-len(split_line[-1])]+common_prefix
+ else:
+ completed = line
+ return completed, possibilities
+
+ def _commonPrefix(self, str1, str2):
+ for i in range(len(str1)):
+ if not str2.startswith(str1[:i+1]):
+ return str1[:i]
+ return str1
+
+ def shell(self, cmd,verbose=0,debug=0,header=''):
+ stat = 0
+ if verbose or debug: print header+cmd
+ # flush stdout so we don't mangle python's buffering
+ if not debug:
+ input, output = os.popen4(cmd)
+ print output.read()
+ output.close()
+ input.close()
+
+class ConsoleView(gtk.TextView):
+ def __init__(self):
+ gtk.TextView.__init__(self)
+ self.modify_font(pango.FontDescription('Mono'))
+ self.set_cursor_visible(True)
+ self.text_buffer = self.get_buffer()
+ self.mark = self.text_buffer.create_mark('scroll_mark',
+ self.text_buffer.get_end_iter(),
+ False)
+ for code in ansi_colors:
+ self.text_buffer.create_tag(code,
+ foreground=ansi_colors[code],
+ weight=700)
+ self.text_buffer.create_tag('0')
+ self.text_buffer.create_tag('notouch', editable=False)
+ self.color_pat = re.compile('\x01?\x1b\[(.*?)m\x02?')
+ self.line_start = \
+ self.text_buffer.create_mark('line_start',
+ self.text_buffer.get_end_iter(), True
+ )
+ self.connect('key-press-event', self._onKeypress)
+ self.last_cursor_pos = 0
+
+ def write(self, text, editable=False):
+ segments = self.color_pat.split(text)
+ segment = segments.pop(0)
+ start_mark = self.text_buffer.create_mark(None,
+ self.text_buffer.get_end_iter(),
+ True)
+ self.text_buffer.insert(self.text_buffer.get_end_iter(), segment)
+
+ if segments:
+ ansi_tags = self.color_pat.findall(text)
+ for tag in ansi_tags:
+ i = segments.index(tag)
+ self.text_buffer.insert_with_tags_by_name(self.text_buffer.get_end_iter(),
+ segments[i+1], tag)
+ segments.pop(i)
+ if not editable:
+ self.text_buffer.apply_tag_by_name('notouch',
+ self.text_buffer.get_iter_at_mark(start_mark),
+ self.text_buffer.get_end_iter())
+ self.text_buffer.delete_mark(start_mark)
+ self.scroll_mark_onscreen(self.mark)
+
+ def showPrompt(self, prompt):
+ self.write(prompt)
+ self.text_buffer.move_mark(self.line_start,self.text_buffer.get_end_iter())
+
+ def changeLine(self, text):
+ iter = self.text_buffer.get_iter_at_mark(self.line_start)
+ iter.forward_to_line_end()
+ self.text_buffer.delete(self.text_buffer.get_iter_at_mark(self.line_start), iter)
+ self.write(text, True)
+
+ def getCurrentLine(self):
+ rv = self.text_buffer.get_slice(self.text_buffer.get_iter_at_mark(self.line_start),
+ self.text_buffer.get_end_iter(), False)
+ return rv
+
+ def showReturned(self, text):
+ iter = self.text_buffer.get_iter_at_mark(self.line_start)
+ iter.forward_to_line_end()
+ self.text_buffer.apply_tag_by_name('notouch',
+ self.text_buffer.get_iter_at_mark(self.line_start),
+ iter)
+ self.write('\n'+text)
+ if text:
+ self.write('\n')
+ self.showPrompt(self.prompt)
+ self.text_buffer.move_mark(self.line_start,self.text_buffer.get_end_iter())
+ self.text_buffer.place_cursor(self.text_buffer.get_end_iter())
+
+ def _onKeypress(self, obj, event):
+ if not event.string:
+ return
+ insert_mark = self.text_buffer.get_insert()
+ insert_iter = self.text_buffer.get_iter_at_mark(insert_mark)
+ selection_mark = self.text_buffer.get_selection_bound()
+ selection_iter = self.text_buffer.get_iter_at_mark(selection_mark)
+ start_iter = self.text_buffer.get_iter_at_mark(self.line_start)
+ if start_iter.compare(insert_iter) <= 0 and \
+ start_iter.compare(selection_iter) <= 0:
+ return
+ elif start_iter.compare(insert_iter) > 0 and \
+ start_iter.compare(selection_iter) > 0:
+ self.text_buffer.place_cursor(start_iter)
+ elif insert_iter.compare(selection_iter) < 0:
+ self.text_buffer.move_mark(insert_mark, start_iter)
+ elif insert_iter.compare(selection_iter) > 0:
+ self.text_buffer.move_mark(selection_mark, start_iter)
+
+
+class IPythonView(ConsoleView, IterableIPShell):
+ def __init__(self):
+ ConsoleView.__init__(self)
+ self.cout = StringIO()
+ IterableIPShell.__init__(self, cout=self.cout,cerr=self.cout,
+ input_func=self.raw_input)
+ self.connect('key_press_event', self.keyPress)
+ self.execute()
+ self.cout.truncate(0)
+ self.showPrompt(self.prompt)
+ self.interrupt = False
+
+ def raw_input(self, prompt=''):
+ if self.interrupt:
+ self.interrupt = False
+ raise KeyboardInterrupt
+ return self.getCurrentLine()
+
+ def keyPress(self, widget, event):
+ if event.state & gtk.gdk.CONTROL_MASK and event.keyval == 99:
+ self.interrupt = True
+ self._processLine()
+ return True
+ elif event.keyval == gtk.keysyms.Return:
+ self._processLine()
+ return True
+ elif event.keyval == gtk.keysyms.Up:
+ self.changeLine(self.historyBack())
+ return True
+ elif event.keyval == gtk.keysyms.Down:
+ self.changeLine(self.historyForward())
+ return True
+ elif event.keyval == gtk.keysyms.Tab:
+ if not self.getCurrentLine().strip():
+ return False
+ completed, possibilities = self.complete(self.getCurrentLine())
+ if len(possibilities) > 1:
+ slice = self.getCurrentLine()
+ self.write('\n')
+ for symbol in possibilities:
+ self.write(symbol+'\n')
+ self.showPrompt(self.prompt)
+ self.changeLine(completed or slice)
+ return True
+
+ def _processLine(self):
+ self.history_pos = 0
+ self.execute()
+ rv = self.cout.getvalue()
+ if rv: rv = rv.strip('\n')
+ self.showReturned(rv)
+ self.cout.truncate(0)
+
diff --git a/tutorius/linear_creator.py b/tutorius/linear_creator.py
index 78e94ce..f664c49 100644
--- a/tutorius/linear_creator.py
+++ b/tutorius/linear_creator.py
@@ -15,12 +15,12 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
-from sugar.tutorius.core import *
-from sugar.tutorius.actions import *
-from sugar.tutorius.filters import *
-
from copy import deepcopy
+from .core import *
+from .actions import *
+from .filters import *
+
class LinearCreator(object):
"""
This class is used to create a FSM from a linear sequence of orders. The
diff --git a/tutorius/overlayer.py b/tutorius/overlayer.py
index 0a3d542..b967739 100644
--- a/tutorius/overlayer.py
+++ b/tutorius/overlayer.py
@@ -58,7 +58,7 @@ class Overlayer(gtk.Layout):
@param overlayed widget to be overlayed. Will be resized to full size.
"""
def __init__(self, overlayed=None):
- gtk.Layout.__init__(self)
+ super(Overlayer, self).__init__()
self._overlayed = overlayed
if overlayed:
@@ -83,7 +83,7 @@ class Overlayer(gtk.Layout):
if hasattr(child, "draw_with_context"):
# if the widget has the CanvasDrawable protocol, use it.
child.no_expose = True
- gtk.Layout.put(self, child, x, y)
+ super(Overlayer, self).put(child, x, y)
# be sure to redraw or the overlay may not show
self.queue_draw()
diff --git a/tutorius/properties.py b/tutorius/properties.py
index 9639010..c7af821 100644
--- a/tutorius/properties.py
+++ b/tutorius/properties.py
@@ -19,12 +19,13 @@ TutoriusProperties have the same behaviour as python properties (assuming you
also use the TPropContainer), with the added benefit of having builtin dialog
prompts and constraint validation.
"""
+from copy import copy, deepcopy
-from sugar.tutorius.constraints import Constraint, \
+from .constraints import Constraint, \
UpperLimitConstraint, LowerLimitConstraint, \
MaxSizeConstraint, MinSizeConstraint, \
- ColorConstraint, FileConstraint, BooleanConstraint, EnumConstraint
-from copy import copy
+ ColorConstraint, FileConstraint, BooleanConstraint, EnumConstraint, \
+ ResourceConstraint
class TPropContainer(object):
"""
@@ -89,12 +90,33 @@ class TPropContainer(object):
except AttributeError:
return object.__setattr__(self, name, value)
+ def replace_property(self, prop_name, new_prop):
+ """
+ Changes the content of a property. This is done in order to support
+ the insertion of executable properties in the place of a portable
+ property. The typical exemple is that a resource property needs to
+ be changed into a file property with the correct file name, since the
+ installation location will be different on every platform.
+
+ @param prop_name The name of the property to be changed
+ @param new_prop The new property to insert
+ @raise AttributeError of the mentionned property doesn't exist
+ """
+ props = object.__getattribute__(self, "_props")
+ props.__setitem__(prop_name, new_prop)
+
def get_properties(self):
"""
Return the list of property names.
"""
return object.__getattribute__(self, "_props").keys()
+ def get_properties_dict_copy(self):
+ """
+ Return a deep copy of the dictionary of properties from that object.
+ """
+ return deepcopy(self._props)
+
# Providing the hash methods necessary to use TPropContainers
# in a dictionary, according to their properties
def __hash__(self):
@@ -104,7 +126,7 @@ class TPropContainer(object):
return hash(tuple(map(tuple,sorted(self._props.items(), cmp=lambda x, y: cmp(x[0], y[0])))))
def __eq__(self, e2):
- return self._props.items() == e2._props.items()
+ return isinstance(e2, type(self)) and self._props == e2._props
# Adding methods for pickling and unpickling an object with
# properties
@@ -164,19 +186,6 @@ class TAddonListProperty(TutoriusProperty):
"""
pass
-
- def get_constraints(self):
- """
- Returns the list of constraints associated to this property.
- """
- if self._constraints is None:
- self._constraints = []
- for i in dir(self):
- typ = getattr(self, i)
- if isinstance(typ, Constraint):
- self._constraints.append(i)
- return self._constraints
-
class TIntProperty(TutoriusProperty):
"""
Represents an integer. Can have an upper value limit and/or a lower value
@@ -226,7 +235,7 @@ class TArrayProperty(TutoriusProperty):
self.type = "array"
self.max_size_limit = MaxSizeConstraint(max_size_limit)
self.min_size_limit = MinSizeConstraint(min_size_limit)
- self.default = self.validate(value)
+ self.default = tuple(self.validate(value))
#Make this thing hashable
def __setstate__(self, state):
@@ -268,8 +277,6 @@ class TFileProperty(TutoriusProperty):
For now, the path may be relative or absolute, as long as it exists on
the local machine.
- TODO : Make sure that we have a file scheme that supports distribution
- on other computers (LP 355197)
"""
TutoriusProperty.__init__(self)
@@ -323,6 +330,8 @@ class TUAMProperty(TutoriusProperty):
self.type = "uam"
+ self.default = self.validate(value)
+
class TAddonProperty(TutoriusProperty):
"""
Reprensents an embedded tutorius Addon Component (action, trigger, etc.)
@@ -344,14 +353,62 @@ class TAddonProperty(TutoriusProperty):
return super(TAddonProperty, self).validate(value)
raise ValueError("Expected TPropContainer instance as TaddonProperty value")
+class TEventType(TutoriusProperty):
+ """
+ Represents an GUI signal for a widget.
+ """
+ def __init__(self, value):
+ super(TEventType, self).__init__()
+ self.type = "gtk-signal"
+
+ self.default = self.validate(value)
+
class TAddonListProperty(TutoriusProperty):
"""
- Reprensents an embedded tutorius Addon List Component.
+ Represents an embedded tutorius Addon List Component.
See TAddonProperty
"""
def __init__(self):
- super(TAddonProperty, self).__init__()
+ TutoriusProperty.__init__(self)
self.type = "addonlist"
self.default = []
+ def validate(self, value):
+ if isinstance(value, list):
+ for component in value:
+ if not (isinstance(component, TPropContainer)):
+ raise ValueError("Expected a list of TPropContainer instances inside TAddonListProperty value, got a %s" % (str(type(component))))
+ return value
+ raise ValueError("Value proposed to TAddonListProperty is not a list")
+
+class TResourceProperty(TutoriusProperty):
+ """
+ Represents a resource in the tutorial. A resource is a file with a specific
+ name that exists under the tutorials folder. It is distributed alongside the
+ tutorial itself.
+
+ When the system encounters a resource, it knows that it refers to a file in
+ the resource folder and that it should translate this resource name to an
+ absolute file name before it is executed.
+
+ E.g. An image is added to a tutorial in an action. On doing so, the creator
+ adds a resource to the tutorial, then saves its name in the resource
+ property of that action. When this tutorial is executed, the Engine
+ replaces all the TResourceProperties inside the action by their equivalent
+ TFileProperties with absolute paths, so that they can be used on any
+ machine.
+ """
+ def __init__(self, resource_name=""):
+ """
+ Creates a new resource pointing to an existing resource.
+
+ @param resource_name The file name of the resource (should be only the
+ file name itself, no directory information)
+ """
+ TutoriusProperty.__init__(self)
+ self.type = "resource"
+
+ self.resource_cons = ResourceConstraint()
+
+ self.default = self.validate("")
diff --git a/tutorius/service.py b/tutorius/service.py
index 21f0cf1..8694cb5 100644
--- a/tutorius/service.py
+++ b/tutorius/service.py
@@ -1,7 +1,10 @@
-from engine import Engine
import dbus
-from dbustools import remote_call
+from .engine import Engine
+from .dbustools import remote_call
+from .TProbe import ProbeManager
+import logging
+LOGGER = logging.getLogger("sugar.tutorius.service")
_DBUS_SERVICE = "org.tutorius.Service"
_DBUS_PATH = "/org/tutorius/Service"
@@ -19,11 +22,13 @@ class Service(dbus.service.Object):
self._engine = None
+ self._probeMgr = ProbeManager()
+
def start(self):
""" Start the service itself
"""
# For the moment there is nothing to do
- pass
+ LOGGER.debug("Service.start()")
@dbus.service.method(_DBUS_SERVICE_IFACE,
@@ -50,6 +55,35 @@ class Service(dbus.service.Object):
"""
self._engine.pause()
+ @dbus.service.method(_DBUS_SERVICE_IFACE,
+ in_signature="ss", out_signature="")
+ def register_probe(self, process_name, unique_id):
+ """ Adds a probe to the known probes, to be used by a tutorial.
+
+ A generic name for a process (like an Activity) is passed
+ so that the execution of a tutorial will use that generic
+ name. However, a unique id is also passed to differentiate
+ between many instances of the same process.
+
+ @param process_name The generic name of a process
+ @param unique_id The unique identification associated to this
+ process
+ """
+ LOGGER.debug("Service.register_probe(%s,%s)", process_name, unique_id)
+ self._probeMgr.register_probe(process_name, unique_id)
+
+ @dbus.service.method(_DBUS_SERVICE_IFACE,
+ in_signature="s", out_signature="")
+ def unregister_probe(self, unique_id):
+ """ Remove a probe from the known probes.
+
+ @param process_name The generic name of a process
+ @param unique_id The unique identification associated to this
+ process
+ """
+ LOGGER.debug("Service.unregister_probe(%s)", unique_id)
+ self._probeMgr.unregister_probe(unique_id)
+
class ServiceProxy:
""" Proxy to connect to the Service object, abstracting the DBus interface"""
@@ -74,6 +108,33 @@ class ServiceProxy:
"""
remote_call(self._service.pause, (), block=False)
+ def register_probe(self, process_name, unique_id):
+ """ Adds a probe to the known probes, to be used by a tutorial.
+
+ A generic name for a process (like an Activity) is passed
+ so that the execution of a tutorial will use that generic
+ name. However, a unique id is also passed to differentiate
+ between many instances of the same process.
+
+ @param process_name The generic name of a process
+ @param unique_id The unique identification associated to this
+ process
+ """
+ remote_call(self._service.register_probe, (process_name,unique_id), block=False)
+
+ def unregister_probe(self, unique_id):
+ """ Remove a probe from the known probes.
+
+ @param process_name The generic name of a process
+ @param unique_id The unique identification associated to this
+ process
+ """
+ # We make it synchronous because otherwise on closing,
+ # activities kill the dbus session bus too fast for the
+ # asynchronous call to be completed
+ self._service.unregister_probe(unique_id)
+
+
if __name__ == "__main__":
import dbus.mainloop.glib
import gobject
diff --git a/tutorius/store.py b/tutorius/store.py
new file mode 100644
index 0000000..81925ed
--- /dev/null
+++ b/tutorius/store.py
@@ -0,0 +1,473 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import urllib
+import urllib2
+from xml.dom import minidom
+from apilib.restful_lib import Connection
+from array import array
+
+class StoreProxy(object):
+ """
+ Implements a communication channel with the Tutorius Store, where tutorials
+ are shared from around the world. This proxy is meant to offer a one-stop
+ shop to implement all the requests that could be made to the Store.
+ """
+
+ def __init__(self, base_url):
+
+ # Base Urls for the api
+ self.base_url = base_url
+ self.remora_api = "api/1.4"
+ self.tutorius_api = "TutoriusApi"
+ self.bandwagon_api = "api/1.4/sharing"
+
+ self.api_auth_key = None
+
+ # Prepares the connection with the api
+ self.conn = Connection(self.base_url)
+
+ # Setup the helper
+ self.helper = StoreProxyHelper()
+
+ def get_categories(self):
+ """
+ Returns all the categories registered in the store. Categories are used to
+ classify tutorials according to a theme. (e.g. Mathematics, History, etc...)
+
+ @return The list of category names stored on the server.
+ """
+
+ request_url = "/%s/categories" % (self.tutorius_api)
+
+ response = self.conn.request_get(request_url)
+
+ if self.helper.iserror(response):
+ return None
+
+ xml_response = minidom.parseString(response['body'])
+
+ xml_categories = xml_response.getElementsByTagName('category')
+
+ categories = list()
+
+ # Loop through the categories and create the list to be returned
+ for xml_category in xml_categories:
+ category = {}
+
+ category['id'] = xml_category.getElementsByTagName('id')[0].firstChild.nodeValue
+ category['name'] = xml_category.getElementsByTagName('name')[0].firstChild.nodeValue
+
+ categories.append(category)
+
+ return categories
+
+ def search(self, keywords, category='all', page=1, numResults=10, sortBy='name'):
+ """
+ Returns a list of tutorials that correspond to the given search criteria.
+
+ @param keywords The keywords to search for
+ @param page The page in the result set from which to return results. This is
+ used to allow applications to fetch results one set at a time.
+ @param numResults The max number of results that can be returned in a page
+ @param sortBy The field on which to sort the results
+ @return A list of tutorial meta-data that corresponds to the query
+ """
+ request_url = "/%s/search/%s/%s/%d/%d/%s" % (self.tutorius_api, keywords, category, page, numResults, sortBy)
+
+ response = self.conn.request_get(request_url)
+
+ if (self.helper.iserror(response)):
+ return None
+
+ xml_response = minidom.parseString(response['body'])
+
+ xml_tutorials = xml_response.getElementsByTagName('tutorial')
+
+ tutorials = list()
+
+ for xml_tutorial in xml_tutorials:
+ tutorial = self.helper.parse_tutorial(xml_tutorial)
+ tutorials.append(tutorial)
+
+ return tutorials
+
+ def get_tutorials(self, category='all', page=1, numResults=10, sortBy='name'):
+ """
+ Returns the list of tutorials that correspond to the given search criteria.
+
+ @param category The category in which to restrict the search.
+ @param page The page in the result set from which to return results. This is
+ used to allow applications to fetch results one set at a time.
+ @param numResults The max number of results that can be returned in a page
+ @param sortBy The field on which to sort the results
+ @return A list of tutorial meta-data that corresponds to the query
+ """
+
+ request_url = "/%s/tutorials/%s/%d/%d/%s" % (self.tutorius_api, category, page, numResults, sortBy)
+
+ response = self.conn.request_get(request_url)
+
+ if (self.helper.iserror(response)):
+ return None
+
+ xml_response = minidom.parseString(response['body'])
+
+ xml_tutorials = xml_response.getElementsByTagName('tutorial')
+
+ tutorials = list()
+
+ for xml_tutorial in xml_tutorials:
+ tutorial = self.helper.parse_tutorial(xml_tutorial)
+ tutorials.append(tutorial)
+
+ return tutorials
+
+ def list(self, type='recommended', numResults=3):
+ """
+ Returns a list of tutorials corresponding to the type specified.
+ Type examples: 'Most downloaded', 'recommended', etc.
+
+ @param type The type of list (Most downloaded, recommended, etc.)
+ @return A list of tutorials
+ """
+ request_url = "/%s/list/%s/tutorial/%s" % (self.remora_api, type, numResults)
+
+ response = self.conn.request_get(request_url)
+
+ if (self.helper.iserror(response)):
+ return None
+
+ xml_response = minidom.parseString(response['body'])
+
+ xml_tutorials = xml_response.getElementsByTagName('addon')
+
+ tutorials = list()
+
+ for xml_tutorial in xml_tutorials:
+ tutorial = self.helper.parse_tutorial(xml_tutorial)
+ tutorials.append(tutorial)
+
+ return tutorials
+
+
+ def get_latest_version(self, tutorial_id_list):
+ """
+ Returns the latest version number on the server, for each tutorial ID
+ in the list.
+
+ @param tutorial_id_list The list of tutorial IDs from which we want to
+ known the latest version number.
+ @return A dictionary having the tutorial ID as the key and the version
+ as the value.
+ """
+
+ versions = {}
+
+ for tutorial_id in tutorial_id_list:
+
+ request_url = "/%s/addon/%s/" % (self.remora_api, tutorial_id)
+
+ response = self.conn.request_get(request_url)
+
+ if (self.helper.iserror(response)):
+ return None
+
+ xml = minidom.parseString(response['body'])
+
+ versionnode = xml.getElementsByTagName("version")[0]
+
+ version = versionnode.firstChild.nodeValue
+
+ versions[tutorial_id] = version
+
+ return versions
+
+ def download_tutorial(self, tutorial_id, version=None):
+ """
+ Fetches the tutorial file from the server and returns the
+
+ @param tutorial_id The tutorial that we want to get
+ @param version The version number that we want to download. If None,
+ the latest version will be downloaded.
+ @return The downloaded file itself (an in-memory representation of the file,
+ not a path to it on the disk)
+
+ TODO : We should decide if we're saving to disk or in mem.
+ """
+ request_url = "/%s/addon/%s/" % (self.remora_api, tutorial_id)
+
+ response = self.conn.request_get(request_url)
+
+ if (self.helper.iserror(response)):
+ return None
+
+ xml = minidom.parseString(response['body'])
+
+ installnode = xml.getElementsByTagName("install")[0]
+ installurl = installnode.firstChild.nodeValue
+
+ fp = urllib.urlopen(installurl)
+
+ return fp
+
+ def login(self, username, password):
+ """
+ Logs in the user on the store and saves the login status in the proxy
+ state. After a successful logon, the operation requiring a login will
+ be successful.
+
+ @param username
+ @param password
+ @return True if the login was successful, False otherwise
+ """
+ request_url = "/%s/auth/" % (self.tutorius_api)
+
+ params = {'username': username, 'password': password}
+
+ response = self.conn.request_post(request_url, params)
+
+ if (self.helper.iserror(response)):
+ return False
+
+ xml_response = minidom.parseString(response['body'])
+
+ keynode = xml_response.getElementsByTagName("token")[0]
+
+ key = keynode.getAttribute('value')
+
+ self.api_auth_key = key
+
+ return True
+
+ def close_session(self):
+ """
+ Ends the user's session on the server and changes the state of the proxy
+ to disallow the calls to the store that requires to be logged in.
+
+ @return True if the user was disconnected, False otherwise
+ """
+ request_url = "/%s/auth/%s" % (self.tutorius_api, self.api_auth_key)
+
+ headers = { 'X-API-Auth' : self.api_auth_key }
+
+ response = self.conn.request_delete(request_url, None, headers)
+
+ if (self.helper.iserror(response)):
+ return False
+
+ self.api_auth_key = None
+
+ return True
+
+ def get_session_id(self):
+ """
+ Gives the current session ID cached in the Store Proxy, or returns
+ None is the user is not logged yet.
+
+ @return The current session's ID, or None if the user is not logged
+ """
+ return self.api_auth_key
+
+ def rate(self, value, tutorial_store_id):
+ """
+ Sends a rating for the given tutorial.
+
+ This function requires the user to be logged in.
+
+ @param value The value of the rating. It must be an integer with a value
+ from 1 to 5.
+ @param tutorial_store_id The ID of the tutorial that was rated
+ @return True if the rating was sent to the Store, False otherwise.
+ """
+ request_url = "/%s/review/%s" % (self.tutorius_api, tutorial_store_id)
+
+ params = {'title': 'from api', 'body': 'from api', 'rating': value}
+ headers = { 'X-API-Auth' : self.api_auth_key }
+
+ response = self.conn.request_post(request_url, params, None, None, headers)
+
+ if self.helper.iserror(response):
+ return False
+
+ return True
+
+ def publish(self, tutorial, tutorial_info=None, tutorial_store_id = None):
+ """
+ Sends a tutorial to the store.
+
+ This function requires the user to be logged in.
+
+ @param tutorial The tutorial file to be sent. Note that this is the
+ content itself and not the path to the file.
+ @param tutorial_info An array containing the tutorial information
+ @return True if the tutorial was sent correctly, False otherwise.
+ """
+
+ # This is in the case we have to re-publish a tutorial
+ if tutorial_store_id is not None:
+ request_url = "/%s/publish/%s" % (self.tutorius_api, tutorial_store_id)
+ headers = { 'X-API-Auth' : self.api_auth_key }
+
+ response = self.conn.request_post(request_url, None, None, None, headers)
+
+ if self.helper.iserror(response):
+ return False
+
+ return True
+
+ # Otherwise, we want to publish a new tutorial
+ if tutorial_info == None:
+ return False
+
+ request_url = "/%s/publish/" % (self.tutorius_api)
+
+ headers = { 'X-API-Auth' : self.api_auth_key }
+
+ response = self.conn.request_post(request_url, tutorial_info, tutorial, tutorial_info['filename'], headers)
+
+ if self.helper.iserror(response):
+ return False
+
+ return True
+
+
+ def unpublish(self, tutorial_store_id):
+ """
+ Removes a tutorial from the server. The user in the current session
+ needs to be the creator for it to be unpublished. This will remove
+ the file from the server and from all its collections and categories.
+
+ This function requires the user to be logged in.
+
+ @param tutorial_store_id The ID of the tutorial to be removed
+ @return True if the tutorial was properly removed from the server
+ """
+ request_url = "/%s/publish/%s" % (self.tutorius_api, tutorial_store_id)
+
+ headers = { 'X-API-Auth' : self.api_auth_key }
+ response = self.conn.request_delete(request_url, None, headers)
+
+ if self.helper.iserror(response):
+ return False
+
+ return True
+
+ def update_published_tutorial(self, tutorial_id, tutorial, tutorial_info):
+ """
+ Sends the new content for the tutorial with the given ID.
+
+ This function requires the user to be logged in.
+
+ @param tutorial_id The ID of the tutorial to be updated
+ @param tutorial The bundled tutorial file content (not a path!)
+ @return True if the tutorial was sent and updated, False otherwise
+ """
+ request_url = "/%s/update/%s" % (self.tutorius_api, tutorial_id)
+
+ headers = { 'X-API-Auth' : self.api_auth_key }
+
+ response = self.conn.request_post(request_url, tutorial_info, tutorial, tutorial_info['filename'], headers)
+
+ if self.helper.iserror(response):
+ return False
+
+ return True
+
+
+ def register_new_user(self, user_info):
+ """
+ Creates a new user from the given user information.
+
+ @param user_info A structure containing all the data required to do a login.
+ @return True if the new account was created, false otherwise
+ """
+ request_url = "/%s/registerNewUser" % (self.tutorius_api)
+
+ params = {'nickname': user_info['nickname'], 'password': user_info['password'], 'email': user_info['email']}
+
+ response = self.conn.request_post(request_url, params)
+
+ if self.helper.iserror(response):
+ return False
+
+ return True
+
+
+class StoreProxyHelper(object):
+ """
+ Implements helper methods for the Store, more specifically
+ methods to handle xml responses and errors
+ """
+ def iserror(self, response):
+ """
+ Check if the response received from the server is an error
+
+ @param response The XML response from the server
+ @return True if the response is an error
+ """
+
+ # first look for HTTP errors
+ http_status = response['headers']['status']
+
+ if http_status in ['400', '401', '403', '500' ]:
+ return True
+
+ # Now check if the response is valid XML
+ try:
+ minidom.parseString(response['body'])
+ except Exception, e:
+ return True
+
+ # The response is valid XML, parse it and look for
+ # an error in xml format
+ xml_response = minidom.parseString(response['body'])
+
+ errors = xml_response.getElementsByTagName('error')
+
+ if (len(errors) > 0):
+ return True
+
+ return False
+
+ def parse_tutorial(self, xml_tutorial):
+ """
+ Parse a tutorial's XML metadata and returns a dictionnary
+ containing the metadata
+
+ @param xml_tutorial The tutorial metadata in XML format
+ @return A dictionnary containing the metadata
+ """
+ tutorial = {}
+
+ params = [
+ 'name',
+ 'summary',
+ 'version',
+ 'description',
+ 'author',
+ 'rating'
+ ]
+
+ for param in params:
+ xml_node = xml_tutorial.getElementsByTagName(param)[0].firstChild
+
+ if xml_node != None:
+ tutorial[param] = xml_node.nodeValue
+ else:
+ tutorial[param] = ''
+
+ return tutorial
diff --git a/tutorius/translator.py b/tutorius/translator.py
new file mode 100644
index 0000000..626e2c9
--- /dev/null
+++ b/tutorius/translator.py
@@ -0,0 +1,186 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import os
+import logging
+import copy
+
+logger = logging.getLogger("ResourceTranslator")
+
+from .properties import *
+# TODO : Uncomment this line upon integration with the Vault
+from .vault import Vault
+
+class ResourceTranslator(object):
+ """
+ Handles the conversion of resource properties into file
+ properties before action execution. This class works as a decorator
+ to the ProbeManager class, as it is meant to be a transparent layer
+ before sending the action to execution.
+
+ An architectural note : every different type of translation should have its
+ own method (translate_resource, etc...), and this function must be called
+ from the translate method, under the type test. The translate_* method
+ must take in the input property and give the output property that should
+ replace it.
+ """
+
+ def __init__(self, probe_manager, tutorial_id):
+ """
+ Creates a new ResourceTranslator for the given tutorial. This
+ translator is tasked with replacing resource properties of the
+ incoming action into actually usable file properties pointing
+ to the correct resource file. This is done by querying the vault
+ for all the resources and creating a new file property from the
+ returned path.
+
+ @param probe_manager The probe manager to decorate
+ @param tutorial_id The ID of the current tutorial
+ """
+ self._probe_manager = probe_manager
+ self._tutorial_id = tutorial_id
+
+ def translate_resource(self, res_value):
+ """
+ Replace the TResourceProperty in the container by their
+ runtime-defined file equivalent. Since the resources are stored
+ in a relative manner in the vault and that only the Vault knows
+ which is the current folder for the current tutorial, it needs
+ to transform the resource identifier into the absolute path for
+ the process to be able to use it properly.
+
+ @param res_prop The resource property's value to be translated
+ @return The TFileProperty corresponding to this resource, containing
+ an absolute path to the resource
+ """
+ # We need to replace the resource by a file representation
+ filepath = Vault.get_resource_path(self._tutorial_id, res_value)
+
+ # Create the new file representation
+ file_prop = TFileProperty(filepath)
+
+ return file_prop
+
+ def translate(self, prop_container):
+ """
+ Applies the required translation to be able to send the container to an
+ executing endpoint (like a Probe). For each type of property that
+ requires it, there is translation function that will take care of
+ mapping the property to its executable form.
+
+ This function does not return anything, but its post-condition is
+ that all the properties of the input container have been replaced
+ by their corresponding executable versions.
+
+ An example of translation is taking a resource (a relative path to
+ a file under the tutorial folder) and transforming it into a file
+ (a full absolute path) to be able to load it when the activity receives
+ the action.
+
+ @param prop_container The property container in which we want to
+ replace all the resources for file properties and
+ to recursively do so for addon and addonlist
+ properties.
+ """
+ for propname in prop_container.get_properties():
+ prop_value = getattr(prop_container, propname)
+ prop_type = getattr(type(prop_container), propname).type
+
+ # If the property is a resource, then we need to query the
+ # vault to create its correspondent
+ if prop_type == "resource":
+ # Apply the translation
+ file_prop = self.translate_resource(prop_value)
+ # Set the property with the new value
+ prop_container.replace_property(propname, file_prop)
+
+ # If the property is an addon, then its value IS a
+ # container too - we need to translate it
+ elif prop_type == "addon":
+ # Translate the sub properties
+ self.translate(prop_value)
+
+ # If the property is an addon list, then we need to translate all
+ # the elements of the list
+ elif prop_type == "addonlist":
+ # Now, for each sub-container in the list, we will apply the
+ # translation processing. This is done by popping the head of
+ # the list, translating it and inserting it back at the end.
+ for index in range(0, len(prop_value)):
+ # Pop the head of the list
+ container = prop_value[0]
+ del prop_value[0]
+ # Translate the sub-container
+ self.translate(container)
+
+ # Put the value back in the list
+ prop_value.append(container)
+ # Change the list contained in the addonlist property, since
+ # we got a copy of the list when requesting it
+ prop_container.replace_property(propname, prop_value)
+
+ ### ProbeManager interface for decorator ###
+
+ ## Unchanged functions ##
+ def setCurrentActivity(self, activity_id):
+ self._probe_manager.setCurrentActivity(activity_id)
+
+ def getCurrentActivity(self):
+ return self._probe_manager.getCurrentActivity()
+
+ currentActivity = property(fget=getCurrentActivity, fset=setCurrentActivity)
+ def attach(self, activity_id):
+ self._probe_manager.attach(activity_id)
+
+ def detach(self, activity_id):
+ self._probe_manager.detach(activity_id)
+
+ def subscribe(self, event, callback):
+ return self._probe_manager.subscribe(event, callback)
+
+ def unsubscribe(self, event, callback):
+ return self._probe_manager.unsubscribe(event, callback)
+
+ def unsubscribe_all(self):
+ return self._probe_manager.unsubscribe_all()
+
+ ## Decorated functions ##
+ def install(self, action):
+ # Make a new copy of the action that we want to install,
+ # because translate() changes the action and we
+ # don't want to modify the caller's action representation
+ new_action = copy.deepcopy(action)
+ # Execute the replacement
+ self.translate(new_action)
+
+ # Send the new action to the probe manager
+ return self._probe_manager.install(new_action)
+
+ def update(self, action):
+ new_action = copy.deepcopy(action)
+ self.translate(new_action)
+
+ return self._probe_manager.update(new_action)
+
+ def uninstall(self, action):
+ new_action = copy.deepcopy(action)
+ self.translate(new_action)
+
+ return self._probe_manager.uninstall(new_action)
+
+ def uninstall_all(self):
+ return self._probe_manager.uninstall_all()
+
diff --git a/tutorius/tutorial.py b/tutorius/tutorial.py
new file mode 100644
index 0000000..b45363f
--- /dev/null
+++ b/tutorius/tutorial.py
@@ -0,0 +1,829 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Erick Lavoie <erick.lavoie@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+#TODO: For notification of modifications on the Tutorial check for GObject and PyDispatcher for inspiration
+
+from .constraints import ConstraintException
+from .properties import TPropContainer
+
+_NAME_SEPARATOR = "/"
+
+class Tutorial(object):
+ """ This class replaces the previous Tutorial class and
+ allows manipulation of the abstract representation
+ of a tutorial as a state machine
+ """
+
+ INIT = "INIT"
+ END = "END"
+ INITIAL_TRANSITION_NAME = INIT + "/transition0"
+
+
+ def __init__(self, name, state_dict=None):
+ """
+ The constructor for the Tutorial. By default, the tutorial contains
+ only an initial state and an end state.
+ The initial state doesn't contain any action but it contains
+ a single automatic transition <Tutorial.INITIAL_TRANSITION_NAME>
+ between the initial state <Tutorial.INIT> and the end state
+ <Tutorial.END>.
+
+ The end state doesn't contain any action nor transition.
+
+ If state_dict is provided, a valid initial state and an end state
+ must be provided.
+
+ @param name The name of the tutorial
+ @param state_dict optional, a valid dictionary of states
+ @raise InvalidStateDictionary
+ """
+ self.name = name
+
+
+ # We will use an adjacency list representation through the
+ # usage of state objects because our graph representation
+ # is really sparse and mostly linear, for a brief
+ # example of graph programming in python see:
+ # http://www.python.org/doc/essays/graphs
+ if not state_dict:
+ self._state_dict = \
+ {Tutorial.INIT:State(name=Tutorial.INIT),\
+ Tutorial.END:State(name=Tutorial.END)}
+
+ self.add_transition(Tutorial.INIT, \
+ (AutomaticTransitionEvent(), Tutorial.END))
+ else:
+ self._state_dict = state_dict
+
+
+
+ # Minimally check for the presence of an INIT and an END
+ # state
+ if not self._state_dict.has_key(Tutorial.INIT):
+ raise Exception("No INIT state found in state_dict")
+
+ if not self._state_dict.has_key(Tutorial.END):
+ raise Exception("No END state found in state_dict")
+
+ # TODO: Validate once validation is working
+ #self.validate()
+
+ # Initialize variables for generating unique names
+ # TODO: We should take the max number from the
+ # existing state names
+ self._state_name_nb = 0
+
+
+ def add_state(self, action_list=(), transition_list=()):
+ """
+ Add a new state to the state machine. The state is
+ initialized with the action list and transition list
+ and a new unique name is returned for this state.
+
+ The actions are added using add_action.
+
+ The transitions are added using add_transition.
+
+ @param action_list The list of valid actions for this state
+ @param transition_list The list of valid transitions
+ @return unique name for this state
+ """
+ name = self._generate_unique_state_name()
+
+ for action in action_list:
+ self._validate_action(action)
+
+ for transition in transition_list:
+ self._validate_transition(transition)
+
+ state = State(name, action_list, transition_list)
+
+ self._state_dict[name] = state
+
+ return name
+
+
+ def add_action(self, state_name, action):
+ """
+ Add an action to a specific state. A name unique throughout the
+ tutorial is generated to refer precisely to this action
+ and is returned.
+
+ The action is validated.
+
+ @param state_name The name of the state to add an action to
+ @param action The action to be added
+ @return unique name for this action
+ @raise LookupError if state_name doesn't exist
+ """
+ if not self._state_dict.has_key(state_name):
+ raise LookupError("Tutorial: state <" + state_name +\
+ "> is not defined")
+
+ self._validate_action(action)
+
+ return self._state_dict[state_name].add_action(action)
+
+ def add_transition(self, state_name, transition):
+ """
+ Add a transition to a specific state. A name unique throughout the
+ tutorial is generated to refer precisely to this transition
+ and is returned. Inserting a duplicate transition will raise
+ an exception.
+
+ The transition is validated.
+
+ @param state_name The name of the state to add a transition to
+ @param transition The transition to be added
+ @return unique name for this action
+ @raise LookupError if state_name doesn't exist
+ @raise TransitionAlreadyExists
+ """
+ if not self._state_dict.has_key(state_name):
+ raise LookupError("Tutorial: state <" + state_name +\
+ "> is not defined")
+
+ self._validate_transition(transition)
+
+ # The unicity of the transition is validated by the state
+ return self._state_dict[state_name].add_transition(transition)
+
+ def update_action(self, action_name, new_properties):
+ """
+ Update the action with action_name with a property dictionary
+ new_properties. If one property update is invalid, the old
+ values are restored and an exception is raised.
+
+ @param action_name The name of the action to update
+ @param new_properties The properties that will update the action
+ @return old properties from the action
+ @raise LookupError if action_name doesn't exist
+ @raise ConstraintException if a property constraint is violated
+ """
+ state_name = self._validate_state_name(action_name)
+
+ #TODO: We should validate that only properties defined on the action
+ # are passed in
+
+ return self._state_dict[state_name].update_action(action_name, new_properties)
+
+ def update_transition(self, transition_name, new_properties=None, new_state=None):
+ """
+ Update the transition with transition_name with new properties and/or
+ a new state to transition to. A None value means that the corresponding
+ value won't be updated. If one property update is invalid, the old
+ values are restored and an exception is raised.
+
+ @param transition_name The name of the transition to replace
+ @param new_properties The properties that will update the transition
+ @param new_state The new state to transition to
+ @return a tuple (old_properties, old_state) with previous values
+ @raise LookupError if transition_name doesn't exist
+ @raise ConstraintException if a property constraint is violated
+ """
+ state_name = self._validate_state_name(transition_name)
+
+ if not self._state_dict.has_key(state_name):
+ raise LookupError("Tutorial: transition <" + transition_name +\
+ "> is not defined")
+
+ if new_state and not self._state_dict.has_key(new_state):
+ raise LookupError("Tutorial: destination state <" + new_state +\
+ "> is not defined")
+
+ #TODO: We should validate that only properties defined on the action
+ # are passed in
+
+ return self._state_dict[state_name].update_transition(transition_name, new_properties, new_state)
+
+ def delete_action(self, action_name):
+ """
+ Delete the action identified by action_name.
+
+ @param action_name The name of the action to be deleted
+ @return the action that has been deleted
+ @raise LookupError if transition_name doesn't exist
+ """
+ state_name = self._validate_state_name(action_name)
+
+ return self._state_dict[state_name].delete_action(action_name)
+
+ def delete_transition(self, transition_name):
+ """
+ Delete the transition identified by transition_name.
+
+ @param transition_name The name of the transition to be deleted
+ @return the transition that has been deleted
+ @raise LookupError if transition_name doesn't exist
+ """
+ state_name = self._validate_state_name(transition_name)
+
+ return self._state_dict[state_name].delete_transition(transition_name)
+
+ def delete_state(self, state_name):
+ """
+ Delete the state, delete all the actions and transitions
+ in this state, update the transitions from the state that
+ pointed to this one to point to the next state and remove all the
+ unreachable states recursively.
+
+ All but the INIT and END states can be deleted.
+
+ @param state_name The name of the state to remove
+ @return The deleted state
+ @raise StateDeletionError when trying to delete the INIT or the END state
+ @raise LookupError if state_name doesn't exist
+ """
+ self._validate_state_name(state_name)
+
+ if state_name == Tutorial.INIT or state_name == Tutorial.END:
+ raise StateDeletionError("<" + state_name + "> cannot be deleted")
+
+ next_states = set(self.get_following_states_dict(state_name).values())
+ previous_states = set(self.get_previous_states_dict(state_name).values())
+
+ # For now tutorials should be completely linear,
+ # let's make sure they are
+ assert len(next_states) <= 1 and len(previous_states) <= 1
+
+ # Update transitions only if they existed
+ if len(next_states) == 1 and len(previous_states) == 1:
+ next_state = next_states.pop()
+ previous_state = previous_states.pop()
+
+ transitions = previous_state.get_transition_dict()
+ for transition_name, (event, state_to_delete) in \
+ transitions.iteritems():
+ self.update_transition(transition_name, None, next_state.name)
+
+ # Since we assume tutorials are linear for now, we do not need
+ # to search for unreachable states
+
+ return self._state_dict.pop(state_name)
+
+
+
+ def get_action_dict(self, state_name=None):
+ """
+ Returns a reference to the dictionary of all actions for a specific
+ state.
+ If no state_name is provided, returns an action dictionary
+ containing actions for all states.
+
+ @param state_name The name of the state to list actions from
+ @return A dictionary of actions with action_name as key and action
+ as value for state_name
+ @raise LookupError if state_name doesn't exist
+ """
+ if state_name and not self._state_dict.has_key(state_name):
+ raise LookupError("Tutorial: state <" + state_name +\
+ "> is not defined")
+ elif state_name:
+ return self._state_dict[state_name].get_action_dict()
+ else:
+ action_dict = {}
+ for state in self._state_dict.itervalues():
+ action_dict.update(state.get_action_dict())
+ return action_dict
+
+ def get_transition_dict(self, state_name=None):
+ """
+ Returns a dictionary of all actions for a specific state.
+ If no state_name is provided, returns an action dictionary
+ containing actions for all states.
+
+ @param state_name The name of the state to list actions from
+ @return A dictionary of transitions with transition_name as key and transition as value for state_name
+ @raise LookupError if state_name doesn't exist
+ """
+ if state_name and not self._state_dict.has_key(state_name):
+ raise LookupError("Tutorial: state <" + state_name +\
+ "> is not defined")
+ elif state_name:
+ return self._state_dict[state_name].get_transition_dict()
+ else:
+ transition_dict = {}
+ for state in self._state_dict.itervalues():
+ transition_dict.update(state.get_transition_dict())
+ return transition_dict
+
+
+ def get_state_dict(self):
+ """
+ Returns a reference to the internal state dictionary used by
+ the Tutorial.
+
+ @return A reference to the dictionary of all the states in the tutorial with state_name as key and state as value
+ """
+ # Maybe we will need to change it for an immutable dictionary
+ # to make sure the internal representation is not modified
+ return self._state_dict
+
+ def get_following_states_dict(self, state_name):
+ """
+ Returns a dictionary of the states that are immediately reachable from
+ a specific state.
+
+ @param state_name The name of the state
+ @raise LookupError if state_name doesn't exist
+ """
+ if not self._state_dict.has_key(state_name):
+ raise LookupError("Tutorial: state <" + state_name +\
+ "> is not defined")
+
+ following_states_dict = {}
+ for (event, next_state) in \
+ self._state_dict[state_name].get_transition_dict().itervalues():
+ following_states_dict[next_state] = self._state_dict[next_state]
+
+ return following_states_dict
+
+ def get_previous_states_dict(self, state_name):
+ """
+ Returns a dictionary of the states that can transition to a
+ specific state.
+
+ @param state_name The name of the state
+ @raise LookupError if state_name doesn't exist
+ """
+ if not self._state_dict.has_key(state_name):
+ raise LookupError("Tutorial: state <" + state_name +\
+ "> is not defined")
+
+
+ previous_states_dict = {}
+ for iter_state_name, state in \
+ self._state_dict.iteritems():
+
+ for (event, next_state) in \
+ self._state_dict[iter_state_name].get_transition_dict().itervalues():
+
+ if next_state != state_name:
+ continue
+
+ previous_states_dict[iter_state_name] = state
+ # if we have found one, do not look for other transitions
+ # from this state
+ break
+
+ return previous_states_dict
+
+ # Convenience methods for common tutorial manipulations
+ def add_state_before(self, state_name, action_list=[], event_list=[]):
+ """
+ Add a new state just before another state state_name. All transitions
+ going to state_name are updated to end on the new state and all
+ events will be converted to transitions ending on state_name.
+
+ When event_list is empty, an automatic transition to state_name
+ will be added to maintain consistency.
+
+ @param state_name The name of the state that will be preceded by the
+ new state
+ @param action_list The list of valid actions for this state
+ @param event_list The list of events that will be converted to transitions to state_name
+ @return unique name for this state
+ @raise LookupError if state_name doesn't exist
+ """
+ raise NotImplementedError
+
+ # Callback mecanism to allow automatic change notification when
+ # the tutorial is modified
+ def register_action_added_cb(self, cb):
+ """
+ Register a function cb that will be called when any action from
+ the tutorial is added.
+
+ cb should be of the form:
+
+ cb(action_name, new_action) where:
+ action_name is the unique name of the action that was added
+ new_action is the new action
+
+ @param cb The callback function to be called
+ @raise InvalidCallbackFunction if the callback has less or more than
+ 2 arguments
+ """
+ raise NotImplementedError
+
+ def register_action_updated_cb(self, cb):
+ """
+ Register a function cb that will be called when any action from
+ the tutorial is updated.
+
+ cb should be of the form:
+
+ cb(action_name, new_action) where:
+ action_name is the unique name of the action that has changed
+ new_action is the new action that replaces the old one
+
+ @param cb The callback function to be called
+ @raise InvalidCallbackFunction if the callback has less or more than
+ 2 arguments
+ """
+ raise NotImplementedError
+
+ def register_action_deleted_cb(self, cb):
+ """
+ Register a function cb that will be called when any action from
+ the tutorial is deleted.
+
+ cb should be of the form:
+
+ cb(action_name, old_action) where:
+ action_name is the unique name of the action that was deleted
+ old_action is the new action that replaces the old one
+
+ @param cb The callback function to be called
+ @raise InvalidCallbackFunction if the callback has less or more than
+ 2 arguments
+ """
+ raise NotImplementedError
+
+ def register_transition_updated_cb(self, cb):
+ """
+ Register a function cb that will be called when any transition from
+ the tutorial is updated.
+
+ cb should be of the form:
+
+ cb(transition_name, new_transition) where:
+ transition_name is the unique name of the transition
+ that has changed
+ new_transition is the new transition that replaces the old one
+
+ @param cb The callback function to be called
+ @raise InvalidCallbackFunction if the callback has less or more than
+ 2 arguments
+ """
+ raise NotImplementedError
+
+ # Validation to assert precondition
+ def _validate_action(self, action):
+ """
+ Validate that an action conforms to what we expect,
+ throws an exception otherwise.
+
+ @param action The action to validate
+ @except InvalidAction if the action fails to conform to what we expect
+ """
+ pass
+
+ def _validate_transition(self, transition):
+ """
+ Validate that a transition conforms to what we expect,
+ throws an exception otherwise.
+
+ @param transition The transition to validate
+ @except InvalidTransition if the transition fails to conform to what we expect
+ """
+ pass
+
+ # Validation decorators to assert preconditions
+ def _validate_state_name(self,name):
+ """
+ Assert that the state name found in the first part of the string
+ actually exists
+
+ @param name The name that starts with a state name
+ @return the state_name from name
+ @raise LookupError if state_name doesn't exist
+ """
+ state_name = name
+
+ if name.find(_NAME_SEPARATOR) != -1:
+ state_name = name[:name.find(_NAME_SEPARATOR)]
+
+ if not self._state_dict.has_key(state_name):
+ raise LookupError("Tutorial: state <" + str(state_name) +\
+ "> is not defined")
+
+ return state_name
+
+ def validate(self):
+ """
+ Validate the state machine for a serie of properties:
+ 1. No unreachable states
+ 2. No dead end state (except END)
+ 3. No branching in the main path
+ 4. No loop in the main path
+ 5. ...
+
+ Throw an exception for the first condition that is not met.
+ """
+ raise NotImplementedError
+
+ def _generate_unique_state_name(self):
+ name = "State" + str(self._state_name_nb)
+ while name in self._state_dict:
+ self._state_name_nb += 1
+ name = "State" + str(self._state_name_nb)
+ return name
+
+ # Python Magic Methods
+ def __str__(self):
+ """
+ Return a string representation of the tutorial
+ """
+ return str(self._state_dict)
+
+ def __eq__(self, other):
+ return isinstance(other, type(self)) and self.get_state_dict() == other.get_state_dict()
+
+class State(object):
+ """
+ This is a step in a tutorial. The state represents a collection of actions
+ to undertake when entering the state, and a series of transitions to lead
+ to next states.
+
+ This class is not meant to be used explicitly as no validation is done on
+ inputs, the validation should be done by the containing class.
+ """
+
+ def __init__(self, name, actions={}, transitions={}):
+ """
+ Initializes the content of the state, such as loading the actions
+ that are required and building the correct transitions.
+
+ @param actions list or dict of actions to perform when entering the
+ state
+ @param transitions list or dict of tuples of the form
+ (event, next_state_name), that explains the outgoing links for
+ this state
+
+ For actions and transitions, dictionaries allow specifying the name.
+ If lists are given, their contents will be added with add_action or
+ add_transition
+ """
+ object.__init__(self)
+
+ self.name = name
+
+ # Initialize internal variables for name generation
+ self.action_name_nb = 0
+ self.transition_name_nb = 0
+
+ if type(actions) is dict:
+ self._actions = dict(actions)
+ else:
+ self._actions = {}
+ for action in actions:
+ self.add_action(action)
+
+ if type(transitions) is dict:
+ self._transitions = dict(transitions)
+ else:
+ self._transitions = {}
+ for transition in transitions:
+ self.add_transition(transition)
+
+
+ # Action manipulations
+ def add_action(self, new_action):
+ """
+ Adds an action to the state
+
+ @param new_action The action to add
+ @return a unique name for this action
+ """
+ action_name = self._generate_unique_action_name(new_action)
+ self._actions[action_name] = new_action
+ return action_name
+
+ def delete_action(self, action_name):
+ """
+ Delete the action with the name action_name
+
+ @param action_name The name of the action to delete
+ @return The action deleted
+ @raise LookupError if action_name doesn't exist
+ """
+ if self._actions.has_key(action_name):
+ return self._actions.pop(action_name)
+ else:
+ raise LookupError("Tutorial.State: action <" + action_name + "> is not defined")
+
+ def update_action(self, action_name, new_properties):
+ """
+ Update the action with action_name with a property dictionary
+ new_properties. If one property update is invalid, the old
+ values are restored and an exception is raised.
+
+ @param action_name The name of the action to update
+ @param new_properties The properties that will update the action
+ @return The old properties from the action
+ @raise LookupError if action_name doesn't exist
+ @raise ConstraintException if a property constraint is violated
+ """
+ if not self._actions.has_key(action_name):
+ raise LookupError("Tutorial.State: action <" + action_name + "> is not defined")
+
+ action = self._actions[action_name]
+ old_properties = action.get_properties_dict_copy()
+ try:
+ for property_name, property_value in new_properties.iteritems():
+ action.__setattr__(property_name, property_value)
+ return old_properties
+ except ConstraintException, e:
+ action._props = old_properties
+ raise e
+
+ def get_action_dict(self):
+ """
+ Return the reference to the internal action dictionary.
+
+ @return A dictionary of actions that the state will execute
+ """
+ return self._actions
+
+ def delete_actions(self):
+ """
+ Removes all the action associated with this state. A cleared state will
+ not do anything when entered or exited.
+ """
+ self._actions = {}
+
+ # Transition manipulations
+ def add_transition(self, new_transition):
+ """
+ Adds a transition from this state to another state.
+
+ The same transition may not be added twice.
+
+ @param transition The new transition.
+ @return A unique name for the transition
+ @raise TransitionAlreadyExists if an equivalent transition exists
+ """
+ for transition in self._transitions.itervalues():
+ if transition == new_transition:
+ raise TransitionAlreadyExists(str(transition))
+
+ transition_name = self._generate_unique_transition_name(new_transition)
+ self._transitions[transition_name] = new_transition
+ return transition_name
+
+ def update_transition(self, transition_name, new_properties=None, new_state=None):
+ """
+ Update the transition with transition_name with new properties and/or
+ a new state to transition to. A None value means that the corresponding
+ value won't be updated. If one property update is invalid, the old
+ values are restored and an exception is raised.
+
+ @param transition_name The name of the transition to replace
+ @param new_properties The properties that will update the event on the transition
+ @param new_state The new state to transition to
+ @return a tuple (old_properties, old_state) with previous values
+ @raise LookupError if transition_name doesn't exist
+ @raise ConstraintException if a property constraint is violated
+ """
+ if not self._transitions.has_key(transition_name):
+ raise LookupError("Tutorial.State: transition <" + transition_name + "> is not defined")
+
+ transition = self._transitions[transition_name]
+
+ tmp_event = transition[0]
+ tmp_state = transition[1]
+
+ prop = new_properties or {}
+
+ old_properties = transition[0].get_properties_dict_copy()
+ old_state = transition[1]
+
+ try:
+ for property_name, property_value in prop.iteritems():
+ tmp_event.__setattr__(property_name, property_value)
+ except ConstraintException, e:
+ tmp_event._props = old_properties
+ raise e
+
+ if new_state:
+ tmp_state = new_state
+
+ self._transitions[transition_name] = (tmp_event, tmp_state)
+
+ return (old_properties, old_state)
+
+ def delete_transition(self, transition_name):
+ """
+ Delete the transition with the name transition_name
+
+ @param transition_name The name of the transition to delete
+ @return The transition deleted
+ @raise LookupError if transition_name doesn't exist
+ """
+ if self._transitions.has_key(transition_name):
+ return self._transitions.pop(transition_name)
+ else:
+ raise LookupError("Tutorial.State: transition <" + transition_name + "> is not defined")
+
+ def get_transition_dict(self):
+ """
+ Return the reference to the internal transition dictionary.
+
+ @return The dictionary of transitions associated with this state.
+ """
+ return self._transitions
+
+ def delete_transitions(self):
+ """
+ Delete all the transitions associated with this state.
+ """
+ self._transitions = {}
+
+ def _generate_unique_action_name(self, action):
+ """
+ Returns a unique name for the action in this state,
+ the actual content of the name should not be relied upon
+ for correct behavior
+
+ @param action The action to generate a name for
+ @return A name garanteed to be unique within this state
+ """
+ #TODO use the action class name to generate a name
+ # to make it easier to debug and know what we are
+ # manipulating
+ name = self.name + _NAME_SEPARATOR + "action" + str(self.action_name_nb)
+ while name in self._actions:
+ self.action_name_nb += 1
+ name = self.name + _NAME_SEPARATOR + "action" + str(self.action_name_nb)
+ return name
+
+ def _generate_unique_transition_name(self, transition):
+ """
+ Returns a unique name for the transition in this state,
+ the actual content of the name should not be relied upon
+ for correct behavior
+
+ @param transition The transition to generate a name for
+ @return A name garanteed to be unique within this state
+ """
+ #TODO use the event class name from the transition to
+ # generate a name to make it easier to debug and know
+ # what we are manipulating
+ name = self.name + _NAME_SEPARATOR + "transition" + str(self.transition_name_nb)
+ while name in self._transitions:
+ self.transition_name_nb += 1
+ name = self.name + _NAME_SEPARATOR + "transition" + str(self.transition_name_nb)
+ return name
+
+ def __eq__(self, otherState):
+ """
+ Compare current state to otherState.
+
+ Two states are considered equal if and only if:
+ -every action in this state has a matching action in the
+ other state with the same properties and values
+ -every event filters in this state has a matching filter in the
+ other state having the same properties and values
+ -both states have the same name.
+
+
+ @param otherState The state that will be compared to this one
+ @return True if the states are the same, False otherwise
+` """
+ return isinstance(otherState, type(self)) and \
+ self.get_action_dict() == otherState.get_action_dict() and \
+ self.get_transition_dict() == otherState.get_transition_dict()
+
+#TODO: Define the automatic transition in the same way as
+# other events
+class AutomaticTransitionEvent(TPropContainer):
+ def __repr__(self):
+ return str(self.__class__.__name__)
+
+
+################## Error Handling and Exceptions ##############################
+
+class TransitionAlreadyExists(Exception):
+ """
+ Raised when a duplicate transition is added to a state
+ """
+ pass
+
+
+class InvalidStateDictionary(Exception):
+ """
+ Raised when an initialization dictionary could not be used to initialize
+ a tutorial
+ """
+ pass
+
+class StateDeletionError(Exception):
+ """
+ Raised when trying to delete an INIT or an END state from a tutorial
+ """
+ pass
diff --git a/tutorius/uam/__init__.py b/tutorius/uam/__init__.py
index 7cf5671..bcd67e1 100644
--- a/tutorius/uam/__init__.py
+++ b/tutorius/uam/__init__.py
@@ -65,7 +65,8 @@ for subscheme in [".".join([SCHEME,s]) for s in __parsers]:
class SchemeError(Exception):
def __init__(self, message):
Exception.__init__(self, message)
- self.message = message
+ ## Commenting this line as it is causing an error in the tests
+ ##self.message = message
def parse_uri(uri):
diff --git a/tutorius/vault.py b/tutorius/vault.py
new file mode 100644
index 0000000..9576de9
--- /dev/null
+++ b/tutorius/vault.py
@@ -0,0 +1,982 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Jean-Christophe Savard <savard.jean.christophe@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+
+"""
+This module contains all the data handling class of Tutorius
+"""
+
+import logging
+import os
+import shutil
+import tempfile
+import uuid
+import xml.dom.minidom
+from xml.dom import NotFoundErr
+import zipfile
+from ConfigParser import SafeConfigParser
+
+from . import addon
+from .tutorial import Tutorial, State, AutomaticTransitionEvent
+
+logger = logging.getLogger("tutorius")
+
+# this is where user installed/generated tutorials will go
+def _get_store_root():
+ profile_name = os.getenv("SUGAR_PROFILE") or "default"
+ return os.path.join(os.getenv("HOME"),
+ ".sugar",profile_name,"tutorius","data")
+# this is where activity bundled tutorials should be, under the activity bundle
+def _get_bundle_root():
+ """
+ Return the path of the bundled activity, or None if not applicable.
+ """
+ if os.getenv("SUGAR_BUNDLE_PATH") != None:
+ return os.path.join(os.getenv("SUGAR_BUNDLE_PATH"),"data","tutorius","data")
+ else:
+ return None
+
+INI_ACTIVITY_SECTION = "RELATED_ACTIVITIES"
+INI_METADATA_SECTION = "GENERAL_METADATA"
+INI_GUID_PROPERTY = "guid"
+INI_NAME_PROPERTY = "name"
+INI_XML_FSM_PROPERTY = "fsm_filename"
+INI_VERSION_PROPERTY = 'version'
+INI_FILENAME = "meta.ini"
+TUTORIAL_FILENAME = "tutorial.xml"
+
+######################################################################
+# XML Tag names and attributes
+######################################################################
+ELEM_FSM = "FSM"
+ELEM_STATES = "States"
+ELEM_STATE = "State"
+ELEM_ACTIONS = "Actions"
+ELEM_TRANS = "Transitions"
+ELEM_AUTOTRANS = "AutomaticTransition"
+NODE_COMPONENT = "Component"
+NODE_SUBCOMPONENT = "property"
+NODE_SUBCOMPONENTLIST = "listproperty"
+NAME_ATTR = "__name__"
+NEXT_STATE_ATTR = "__next_state__"
+START_STATE_ATTR = "__start_state__"
+RESSOURCES_FOLDER = 'ressources'
+
+class Vault(object):
+
+ ## Vault internal functions :
+ @staticmethod
+ def list_available_tutorials(activity_name = None, activity_vers = 0):
+ """
+ Generate the list of all tutorials present on disk for a
+ given activity.
+
+ @param activity_name the name of the activity associated with this tutorial. None means ALL activities
+ @param activity_vers the version number of the activity to find tutorail for. 0 means find for ANY version. If activity_name is None, version number is not used
+ @returns a map of tutorial {names : GUID}.
+ """
+ # check both under the activity data and user installed folders
+ if _get_bundle_root() != None:
+ paths = [_get_store_root(), _get_bundle_root()]
+ else:
+ paths = [_get_store_root()]
+
+ tutoGuidName = {}
+
+ for repository in paths:
+ # (our) convention dictates that tutorial folders are named
+ # with their GUID (for unicity)
+ try:
+ for tuto in os.listdir(repository):
+ parser = SafeConfigParser()
+ file = parser.read(os.path.join(repository, tuto, INI_FILENAME))
+ if file != []:
+ # If parser can read at least section
+ guid = parser.get(INI_METADATA_SECTION, INI_GUID_PROPERTY)
+ name = parser.get(INI_METADATA_SECTION, INI_NAME_PROPERTY)
+ activities = parser.options(INI_ACTIVITY_SECTION)
+ # enforce matching activity name AND version, as UI changes
+ # break tutorials. We may lower this requirement when the
+ # UAM gets less dependent on the widget order.
+ # Also note property names are always stored lowercase.
+ if (activity_name != None) and (activity_name.lower() in activities):
+ version = parser.get(INI_ACTIVITY_SECTION, activity_name)
+ if (activity_vers == version) or (activity_vers == 0):
+ tutoGuidName[guid] = name
+ elif (activity_name == None):
+ tutoGuidName[guid] = name
+ except OSError:
+ # the repository may not exist. Continue scanning
+ pass
+
+ return tutoGuidName
+
+ ## Vault interface functions :
+ @staticmethod
+ def installTutorials(path, zip_file_name, forceinstall=False):
+ """
+ Extract the tutorial files in the ZIPPED tutorial archive at the
+ specified path and add them inside the vault. This should remove any previous
+ version of this tutorial, if there's any. On the opposite, if we are
+ trying to install an earlier version, the function will return 1 if
+ forceInstall is not set to true.
+
+ @params path The path where the zipped tutorial archive is present
+ @params forceinstall A flag that indicate if we need to force overwrite
+ of a tutorial even if is version number is lower than the existing one.
+
+ @returns 0 if it worked, 1 if the user needs to confirm the installation
+ and 2 to mean an error happened
+ """
+ # TODO : Check with architecture team for exception vs error returns
+
+ # test if the file is a valid pkzip file
+ if zipfile.is_zipfile(os.path.join(path, zip_file_name)) != True:
+ assert False, "Error : The given file is not a valid PKZip file"
+
+ # unpack the zip archive
+ zfile = zipfile.ZipFile(os.path.join(path, zip_file_name), "r" )
+
+ temp_path = tempfile.mkdtemp(dir=_get_store_root())
+ zfile.extractall(temp_path)
+
+ # get the tutorial file
+ ini_file_path = os.path.join(temp_path, INI_FILENAME)
+ ini_file = SafeConfigParser()
+ ini_file.read(ini_file_path)
+
+ # get the tutorial guid
+ guid = ini_file.get(INI_METADATA_SECTION, INI_GUID_PROPERTY)
+
+ # Check if tutorial already exist
+ tutorial_path = os.path.join(_get_store_root(), guid)
+ if os.path.isdir(tutorial_path) == False:
+ # Copy the tutorial in the Vault
+ shutil.copytree(temp_path, tutorial_path)
+
+ else:
+ # Check the version of the existing tutorial
+ existing_version = ini_file.get(INI_METADATA_SECTION, INI_VERSION_PROPERTY)
+ # Check the version of the new tutorial
+ new_ini_file = SafeConfigParser()
+ new_ini_file.read(os.path.join(tutorial_path, INI_FILENAME))
+ new_version = new_ini_file.get(INI_METADATA_SECTION, INI_VERSION_PROPERTY)
+
+ if new_version < existing_version and forceinstall == False:
+ # Version of new tutorial is older and forceinstall is false, return exception
+ return 1
+ else :
+ # New tutorial is newer or forceinstall flag is set, can overwrite the existing tutorial
+ shutil.rmtree(tutorial_path)
+ shutil.copytree(temp_path, tutorial_path)
+
+ # Remove temp data
+ shutil.rmtree(temp_path)
+
+ return 0
+
+ @staticmethod
+ def query(keyword=[], relatedActivityNames=[], category=[]):
+ """
+ Returns the list of tutorials that corresponds to the specified parameters.
+
+ @returns a list of Tutorial meta-data (TutorialID, Description,
+ Rating, Category, PublishState, etc...)
+ TODO : Search for tuto caracterised by the entry : OR between [], and between each
+
+ The returned dictionnary is of this format : key = property name, value = property value
+ The dictionnary also contain one dictionnary element whose key is the string 'activities'
+ and whose value is another dictionnary of this form : key = related activity name,
+ value = related activity version number
+ """
+
+ # Temp solution for returning all tutorials metadata
+
+ tutorial_list = []
+ tuto_guid_list = []
+ ini_file = SafeConfigParser()
+ if keyword == [] and relatedActivityNames == [] and category == []:
+ # get all tutorials tuples (name:guid) for all activities and version
+ tuto_dict = Vault.list_available_tutorials()
+ for id in tuto_dict.keys():
+ tuto_guid_list.append(id)
+
+ # Find .ini metadata files with the guid list
+
+ # Get the guid from the tuto tuples
+ for guid in tuto_guid_list:
+ # Create a dictionnary containing the metadata and also
+ # another dictionnary containing the tutorial Related Acttivities,
+ # and add it to a list
+
+ # Create a TutorialBundler object from the guid
+ bundler = TutorialBundler(guid)
+ # Find the .ini file path for this guid
+ ini_file_path = bundler.get_tutorial_path(guid)
+ # Read the .ini file
+ ini_file.read(os.path.join(ini_file_path, 'meta.ini'))
+
+ metadata_dictionnary = {}
+ related_act_dictionnary = {}
+ metadata_list = ini_file.options(INI_METADATA_SECTION)
+ for metadata_name in metadata_list:
+ # Create a dictionnary of tutorial metadata
+ metadata_dictionnary[metadata_name] = ini_file.get(INI_METADATA_SECTION, metadata_name)
+ # Get Related Activities data from.ini files
+ related_act_list = ini_file.options(INI_ACTIVITY_SECTION)
+ for related_act in related_act_list:
+ # For related activites, the format is : key = activity name, value = activity version
+ related_act_dictionnary[related_act] = ini_file.get(INI_ACTIVITY_SECTION, related_act)
+
+ # Add Related Activities dictionnary to metadata dictionnary
+ metadata_dictionnary['activities'] = related_act_dictionnary
+
+ # Add this dictionnary to tutorial list
+ tutorial_list.append(metadata_dictionnary)
+
+ # Return tutorial list
+ return tutorial_list
+
+ @staticmethod
+ def loadTutorial(Guid):
+ """
+ Creates an executable version of a tutorial from its saved representation.
+ @param Guid Unique identifier of the tutorial
+ @returns Tutorial object
+ """
+
+ bundle = TutorialBundler(Guid)
+ bundle_path = bundle.get_tutorial_path(Guid)
+ config = SafeConfigParser()
+ config.read(os.path.join(bundle_path, INI_FILENAME))
+
+ serializer = XMLSerializer()
+
+ name = config.get(INI_METADATA_SECTION, INI_NAME_PROPERTY)
+
+ # Open the XML file
+ tutorial_file = os.path.join(bundle_path, TUTORIAL_FILENAME)
+ with open(tutorial_file, 'r') as tfile:
+ tutorial = serializer.load_tutorial(tfile)
+
+ return tutorial
+
+ @staticmethod
+ def saveTutorial(tutorial, metadata_dict):
+ """
+ Creates a persistent version of a tutorial in the Vault.
+ @param tutorial Tutorial
+ @param metadata_dict dictionary of metadata for the Tutorial
+ @returns true if the tutorial was saved correctly
+ """
+
+ # Get the tutorial guid from metadata dictionnary
+ guid = metadata_dict[INI_GUID_PROPERTY]
+
+ # Check if tutorial already exist
+ tutorial_path = os.path.join(_get_store_root(), guid)
+ if os.path.isdir(tutorial_path) == False:
+
+ # Serialize the tutorial and write it to disk
+ xml_ser = XMLSerializer()
+ os.makedirs(tutorial_path)
+
+ with open(os.path.join(tutorial_path, TUTORIAL_FILENAME), 'w') as fsmfile:
+ xml_ser.save_tutorial(tutorial, fsmfile)
+
+ # Create the metadata file
+ ini_file_path = os.path.join(tutorial_path, "meta.ini")
+ parser = SafeConfigParser()
+ parser.add_section(INI_METADATA_SECTION)
+ for key, value in metadata_dict.items():
+ if key != 'activities':
+ parser.set(INI_METADATA_SECTION, key, value)
+ else:
+ related_activities_dict = value
+ parser.add_section(INI_ACTIVITY_SECTION)
+ for related_key, related_value in related_activities_dict.items():
+ parser.set(INI_ACTIVITY_SECTION, related_key, related_value)
+
+ # Write the file to disk
+ with open(ini_file_path, 'wb') as configfile:
+ parser.write(configfile)
+
+ else:
+ # Error, tutorial already exist
+ return False
+
+ # TODO : wait for Ben input on how to unpublish tuto before coding this function
+ # For now, no unpublishing will occur.
+
+
+ @staticmethod
+ def deleteTutorial(tutorial_id):
+ """
+ Removes the tutorial from the Vault. It will unpublish the tutorial if need be,
+ and it will also wipe it from the persistent storage.
+ @returns true is the tutorial was deleted from the Vault
+ """
+ bundle = TutorialBundler(tutorial_id)
+ bundle_path = bundle.get_tutorial_path(tutorial_id)
+
+ # TODO : Need also to unpublish tutorial, need to interact with webservice module
+
+ shutil.rmtree(bundle_path)
+ if os.path.isdir(bundle_path) == False:
+ return True
+ else:
+ return False
+
+
+ @staticmethod
+ def add_resource(tutorial_guid, file_path):
+ """
+ Returns a unique name for this resource composed from the original name of the file
+ and a suffix to make it unique ( ex: name_1.jpg ) and add it to the resources for the tutorial.
+ @param tutorial_guid The guid of the tutorial
+ @param file_path the file path of the ressource to add
+ @returns the ressource_id of the ressource
+ """
+
+ # Get the tutorial path
+ bundler = TutorialBundler(tutorial_guid)
+ tutorial_path = bundler.get_tutorial_path(tutorial_guid)
+ # Get the file name
+ fname_splitted = file_path.rsplit('/')
+ file_name = fname_splitted[fname_splitted.__len__() - 1]
+ base_name, extension = os.path.splitext(file_name)
+ # Append unique name to file name
+ file_name_appended = base_name + '_' + str(uuid.uuid1()) + extension
+ # Check if the ressource file already exists
+ new_file_path = os.path.join(tutorial_path, RESSOURCES_FOLDER, file_name_appended)
+ if os.path.isfile(new_file_path) == False:
+ # Copy the ressource file in the vault
+ if os.path.isdir(os.path.join(tutorial_path, RESSOURCES_FOLDER)) == False:
+ os.makedirs(os.path.join(tutorial_path, RESSOURCES_FOLDER))
+ assert os.path.isfile(file_path)
+ shutil.copyfile(file_path, new_file_path)
+
+ return file_name_appended
+
+
+ @staticmethod
+ def delete_resource(tutorial_guid, ressource_id):
+ """
+ Delete the resource from the resources of the tutorial.
+ @param tutorial_guid the guid of the tutorial
+ @param ressource_id the ressource id of the ressource to delete
+ """
+ # Get the tutorial path
+ bundler = TutorialBundler(tutorial_guid)
+ tutorial_path = bundler.get_tutorial_path(tutorial_guid)
+ # Check if the ressource file exists
+ file_path = os.path.join(tutorial_path, RESSOURCES_FOLDER, ressource_id)
+ if os.path.isfile(file_path):
+ # Delete the ressource
+ os.remove(file_path)
+ else:
+ print('File not found, no delete took place')
+
+ @staticmethod
+ def get_resource_path(tutorial_guid, ressource_id):
+ """
+ Returns the absolute file path to the resourceID
+ @param tutorial_guid the guid of the tutorial
+ @param ressource_id the ressource id of the ressource to find the path for
+ @returns the absolute path of the ressource file
+ """
+ # Get the tutorial path
+ bundler = TutorialBundler(tutorial_guid)
+ tutorial_path = bundler.get_tutorial_path(tutorial_guid)
+ # Check if the ressource file exists
+ file_path = os.path.join(tutorial_path, RESSOURCES_FOLDER, ressource_id)
+ if os.path.isfile(file_path):
+ return file_path
+ else:
+ return None
+
+
+class Serializer(object):
+ """
+ Interface that provide serializing and deserializing of the FSM
+ used in the tutorials to/from disk. Must be inherited.
+ """
+
+ def save_tutorial(self,fsm):
+ """
+ Save fsm to disk. If a GUID parameter is provided, the existing GUID is
+ located in the .ini files in the store root and bundle root and
+ the corresponding FSM is/are overwritten. If the GUId is not found, an
+ exception occur. If no GUID is provided, FSM is written in a new file
+ in the store root.
+ """
+ raise NotImplementedError()
+
+ def load_tutorial(self):
+ """
+ Load fsm from disk.
+ """
+ raise NotImplementedError()
+
+class XMLSerializer(Serializer):
+ """
+ Class that provide serializing and deserializing of the FSM
+ used in the tutorials to/from a .xml file. Inherit from Serializer
+ """
+
+ @classmethod
+ def _create_state_dict_node(cls, state_dict, doc):
+ """
+ Create and return a xml Node from a State dictionnary.
+ @param state_dict dictionary of State objects
+ @param doc The XML document root (used to create nodes only
+ @return xml Element containing the states
+ """
+ statesList = doc.createElement(ELEM_STATES)
+ for state_name, state in state_dict.items():
+ stateNode = doc.createElement(ELEM_STATE)
+ statesList.appendChild(stateNode)
+ stateNode.setAttribute("Name", state_name)
+ actionsList = stateNode.appendChild(cls._create_action_list_node(state.get_action_dict(), doc))
+ transitionsList = stateNode.appendChild(cls._create_transitions_node(state.get_transition_dict(), doc))
+ return statesList
+
+ @classmethod
+ def _create_addon_component_node(cls, parent_attr_name, comp, doc):
+ """
+ Takes a component that is embedded in another component (e.g. the content
+ of a OnceWrapper) and encapsulate it in a node with the property name.
+
+ e.g.
+ <Component Class="OnceWrapper">
+ <property name="addon">
+ <Component Class="BubbleMessage" message="'Hi!'" position="[12,32]"/>
+ </property>
+ </Component>
+
+ When reloading this node, we should look up the property name for the parent
+ in the attribute of the node, then examine the subnode to create the addon
+ object itself.
+
+ @param parent_attr_name The name of the parent's attribute for this addon
+ e.g. the OnceWrapper has the action attribute, which corresponds to a
+ sub-action it must execute once.
+ @param comp The component node itself
+ @param doc The XML document root (only used to create the nodes)
+ @returns A NODE_SUBCOMPONENT node, with the property attribute and a sub node
+ that represents another component.
+ """
+ subCompNode = doc.createElement(NODE_SUBCOMPONENT)
+ subCompNode.setAttribute("name", parent_attr_name)
+
+ subNode = cls._create_component_node(comp, doc)
+
+ subCompNode.appendChild(subNode)
+
+ return subCompNode
+
+ @classmethod
+ def _create_addonlist_component_node(cls, parent_attr_name, comp_list, doc):
+ """
+ Takes a list of components that are embedded in another component (ex. the
+ content of a ChainAction) and encapsulate them in a node with the property
+ name.
+
+ e.g.
+ <Component Class="ChainAction">
+ <listproperty name="actions">
+ <Component Class="BubbleMessage" message="'Hi!'" position="[15,35]"/>
+ <Component Class="DialogMessage" message="'Multi-action!'" position="[45,10]"/>
+ </listproperty>
+ </Component>
+
+ When reloading this node, we should look up the property name for the parent
+ in the the attribute of the node, then rebuild the list by appending the
+ content of all the subnodes.
+
+ @param parent_attr_name The name of the parent component's property
+ @param comp_list A list of components that comprise the property
+ @param doc The XML document root (only for creating new nodes)
+ @returns A NODE_SUBCOMPONENTLIST node with the property attribute
+ """
+ subCompListNode = doc.createElement(NODE_SUBCOMPONENTLIST)
+ subCompListNode.setAttribute("name", parent_attr_name)
+
+ for comp in comp_list:
+ compNode = cls._create_component_node(comp, doc)
+ subCompListNode.appendChild(compNode)
+
+ return subCompListNode
+
+ @classmethod
+ def _create_component_node(cls, comp, doc):
+ """
+ Takes a single component (action or eventfilter) and transforms it
+ into a xml node.
+
+ @param comp A single component
+ @param doc The XML document root (used to create nodes only
+ @return A XML Node object with the component tag name
+ """
+ compNode = doc.createElement(NODE_COMPONENT)
+
+ # Write down just the name of the Action class as the Class
+ # property --
+ compNode.setAttribute("Class",type(comp).__name__)
+
+ # serialize all tutorius properties
+ for propname in comp.get_properties():
+ propval = getattr(comp, propname)
+ if getattr(type(comp), propname).type == "addonlist":
+ compNode.appendChild(cls._create_addonlist_component_node(propname, propval, doc))
+ elif getattr(type(comp), propname).type == "addon":
+ #import rpdb2; rpdb2.start_embedded_debugger('pass')
+ compNode.appendChild(cls._create_addon_component_node(propname, propval, doc))
+ else:
+ # repr instead of str, as we want to be able to eval() it into a
+ # valid object.
+ compNode.setAttribute(propname, repr(propval))
+
+ return compNode
+
+ @classmethod
+ def _create_action_list_node(cls, action_dict, doc):
+ """
+ Create and return a xml Node from a Action list.
+
+ @param action_dict Dictionary of actions with names as keys
+ @param doc The XML document root (used to create new nodes only)
+ @return A XML Node object with the Actions tag name and a serie of
+ Action children
+ """
+ actionsList = doc.createElement(ELEM_ACTIONS)
+ for name, action in action_dict.items():
+ # Create the action node
+ actionNode = cls._create_component_node(action, doc)
+ actionNode.setAttribute(NAME_ATTR, name)
+ # Append it to the list
+ actionsList.appendChild(actionNode)
+
+ return actionsList
+
+ @classmethod
+ def _create_transitions_node(cls, transition_dict, doc):
+ """
+ Create and return a xml Node from a transition dictionary.
+ @param transition_dict dictionary of (event, next_state) transitions.
+ @param doc The XML document root (used to create nodes only
+ @return xml Element containing the transitions
+ """
+ eventFiltersList = doc.createElement(ELEM_TRANS)
+ for transition_name, (event, end_state) in transition_dict.items():
+ #start_state = transition_name.split(Tutorial._NAME_SEPARATOR)[0]
+ #XXX The addon is not in the cache and cannot be loaded so we
+ # store it differently for now
+ if type(event) == AutomaticTransitionEvent:
+ eventFilterNode = doc.createElement(ELEM_AUTOTRANS)
+ else:
+ eventFilterNode = cls._create_component_node(event, doc)
+ #eventFilterNode.setAttribute(START_STATE_ATTR, unicode(start_state))
+ eventFilterNode.setAttribute(NEXT_STATE_ATTR, unicode(end_state))
+ eventFilterNode.setAttribute(NAME_ATTR, transition_name)
+ eventFiltersList.appendChild(eventFilterNode)
+
+ return eventFiltersList
+
+ @classmethod
+ def save_tutorial(cls, fsm, file_obj):
+ """
+ Save fsm to file
+
+ @param fsm Tutorial to save
+ @param file_obj file-like object in which the serialized fsm is saved
+
+ Side effects:
+ A serialized version of the Tutorial is written to file_obj.
+ The file is not closed automatically.
+ """
+ doc = xml.dom.minidom.Document()
+ fsm_element = doc.createElement(ELEM_FSM)
+ doc.appendChild(fsm_element)
+
+ fsm_element.setAttribute("Name", fsm.name)
+
+ states = cls._create_state_dict_node(fsm.get_state_dict(), doc)
+ fsm_element.appendChild(states)
+
+ file_obj.write(doc.toprettyxml())
+
+ @classmethod
+ def _get_direct_descendants_by_tag_name(cls, node, name):
+ """
+ Searches in the list of direct descendants of a node to find all the node
+ that have the given name.
+
+ This is used because the Document.getElementsByTagName() function returns the
+ list of all the descendants (whatever their distance to the start node) that
+ have that name. In the case of complex components, we absolutely need to inspect
+ a single layer of the tree at the time.
+
+ @param node The node from which we want the direct descendants with a particular
+ name
+ @param name The name of the node
+ @returns A list, possibly empty, of direct descendants of node that have this name
+ """
+ return_list = []
+ for childNode in node.childNodes:
+ if childNode.nodeName == name:
+ return_list.append(childNode)
+ return return_list
+
+ @classmethod
+ def _load_xml_transitions(cls, filters_elem):
+ """
+ Loads up a list of Event Filters.
+
+ @param filters_elem An XML Element representing a list of event filters
+ @return dict of (event, next_state) transitions, keyed by name
+ """
+ transition_dict = {}
+
+ #Retrieve normal transitions
+ transition_element_list = cls._get_direct_descendants_by_tag_name(filters_elem, NODE_COMPONENT)
+ new_transition = None
+
+ for transition in transition_element_list:
+ #start_state = transition.getAttribute(START_STATE_ATTR)
+ next_state = transition.getAttribute(NEXT_STATE_ATTR)
+ transition_name = transition.getAttribute(NAME_ATTR)
+ try:
+ #The attributes must be removed so that they are not
+ # viewed as a property in load_xml_component
+ # transition.removeAttribute(START_STATE_ATTR)
+ transition.removeAttribute(NEXT_STATE_ATTR)
+ transition.removeAttribute(NAME_ATTR)
+ except NotFoundErr:
+ continue
+
+ new_transition = cls._load_xml_component(transition)
+
+ if new_transition is not None:
+ transition_dict[transition_name] = (new_transition, next_state)
+
+ #Retrieve automatic transitions
+ # XXX This is done differently as the AutomaticTransitionEvent
+ # cannot be loaded dynamically (yet?)
+ transition_element_list = cls._get_direct_descendants_by_tag_name(filters_elem, ELEM_AUTOTRANS)
+ new_transition = None
+
+ for transition in transition_element_list:
+ #start_state = transition.getAttribute(START_STATE_ATTR)
+ next_state = transition.getAttribute(NEXT_STATE_ATTR)
+ transition_name = transition.getAttribute(NAME_ATTR)
+ try:
+ #The attributes must be removed so that they are not
+ # viewed as a property in load_xml_component
+ # transition.removeAttribute(START_STATE_ATTR)
+ transition.removeAttribute(NEXT_STATE_ATTR)
+ transition.removeAttribute(NAME_ATTR)
+ except NotFoundErr:
+ continue
+
+ transition_dict[transition_name] = (AutomaticTransitionEvent(), next_state)
+
+ return transition_dict
+
+ @classmethod
+ def _load_xml_subcomponents(cls, node, properties):
+ """
+ Loads all the subcomponent node below the given node and inserts them with
+ the right property name inside the properties dictionnary.
+
+ @param node The parent node that contains one or many property nodes.
+ @param properties A dictionnary where the subcomponent property names
+ and the instantiated components will be stored
+ @returns Nothing. The properties dict will contain the property->comp mapping.
+ """
+ subCompList = cls._get_direct_descendants_by_tag_name(node, NODE_SUBCOMPONENT)
+
+ for subComp in subCompList:
+ property_name = subComp.getAttribute("name")
+ internal_comp_node = cls._get_direct_descendants_by_tag_name(subComp, NODE_COMPONENT)[0]
+ internal_comp = cls._load_xml_component(internal_comp_node)
+ properties[str(property_name)] = internal_comp
+
+ @classmethod
+ def _load_xml_subcomponent_lists(cls, node, properties):
+ """
+ Loads all the subcomponent lists below the given node and stores them
+ under the correct property name for that node.
+
+ @param node The node from which we want to read the subComponent lists
+ @param properties The dictionnary that will contain the mapping of prop->subCompList
+ @returns Nothing. The values are returns inside the properties dict.
+ """
+ listOf_subCompListNode = cls._get_direct_descendants_by_tag_name(node, NODE_SUBCOMPONENTLIST)
+ for subCompListNode in listOf_subCompListNode:
+ property_name = subCompListNode.getAttribute("name")
+ subCompList = []
+ for subCompNode in cls._get_direct_descendants_by_tag_name(subCompListNode, NODE_COMPONENT):
+ subComp = cls._load_xml_component(subCompNode)
+ subCompList.append(subComp)
+ properties[str(property_name)] = subCompList
+
+ @classmethod
+ def _load_xml_component(cls, node):
+ """
+ Loads a single addon component instance from an Xml node.
+
+ @param node The component XML Node to transform
+ object
+ @return The addon component object of the correct type according to the XML
+ description
+ """
+ class_name = node.getAttribute("Class")
+
+ properties = {}
+
+ for prop in node.attributes.keys():
+ if prop == "Class" : continue
+ # security : keep sandboxed
+ properties[str(prop)] = eval(node.getAttribute(prop))
+
+ # Read the complex attributes
+ cls._load_xml_subcomponents(node, properties)
+ cls._load_xml_subcomponent_lists(node, properties)
+
+ new_action = addon.create(class_name, **properties)
+
+ if not new_action:
+ return None
+
+ return new_action
+
+ @classmethod
+ def _load_xml_actions(cls, actions_elem):
+ """
+ Transforms an Actions element into a dict of instanciated Action.
+
+ @param actions_elem An XML Element representing a list of Actions
+ @return dictionary of actions keyed by name
+ """
+ action_dict = {}
+ actions_element_list = cls._get_direct_descendants_by_tag_name(actions_elem, NODE_COMPONENT)
+
+ for action in actions_element_list:
+ action_name = action.getAttribute(NAME_ATTR)
+ try:
+ #The name attribute must be removed so that it is not
+ # viewed as a property in load_xml_component
+ action.removeAttribute(NAME_ATTR)
+ except NotFoundErr:
+ continue
+ new_action = cls._load_xml_component(action)
+
+ action_dict[action_name] = new_action
+
+ return action_dict
+
+ @classmethod
+ def _load_xml_states(cls, states_elem):
+ """
+ Takes in a States element and fleshes out a complete list of State
+ objects.
+
+ @param states_elem An XML Element that represents a list of States
+ @return dictionary of States
+ """
+ state_dict = {}
+ # item(0) because there is always only one <States> tag in the xml file
+ # so states_elem should always contain only one element
+ states_element_list = states_elem.item(0).getElementsByTagName(ELEM_STATE)
+
+ for state in states_element_list:
+ stateName = state.getAttribute("Name")
+ # Using item 0 in the list because there is always only one
+ # Actions and EventFilterList element per State node.
+ actions_list = cls._load_xml_actions(state.getElementsByTagName(ELEM_ACTIONS)[0])
+ transitions_list = cls._load_xml_transitions(state.getElementsByTagName(ELEM_TRANS)[0])
+
+ state_dict[stateName] = State(stateName, actions_list, transitions_list)
+
+ return state_dict
+
+ @classmethod
+ def load_xml_tutorial(cls, fsm_elem):
+ """
+ Takes in an XML element representing an FSM and returns the fully
+ crafted FSM.
+
+ @param fsm_elem The XML element that describes a FSM
+ @return Tutorial loaded from xml element
+ """
+ # Load the FSM's name and start state's name
+ fsm_name = fsm_elem.getAttribute("Name")
+
+ # Load the states
+ states_dict = cls._load_xml_states(fsm_elem.getElementsByTagName(ELEM_STATES))
+ fsm = Tutorial(fsm_name, states_dict)
+
+ return fsm
+
+ @classmethod
+ def load_tutorial(cls, tutorial_file):
+ """
+ Load fsm from xml file
+ @param tutorial_file file-like object to read the fsm from
+ @return Tutorial object that was loaded from the file
+ """
+ xml_dom = xml.dom.minidom.parse(tutorial_file)
+
+ fsm_elem = xml_dom.getElementsByTagName(ELEM_FSM)[0]
+
+ return cls.load_xml_tutorial(fsm_elem)
+
+class TutorialBundler(object):
+ """
+ This class provide the various data handling methods useable by the tutorial
+ editor.
+ """
+
+ def __init__(self,generated_guid = None, bundle_path=None):
+ """
+ Tutorial_bundler constructor. If a GUID is given in the parameter, the
+ Tutorial_bundler object will be associated with it. If no GUID is given,
+ a new GUID will be generated,
+ """
+
+ self.Guid = generated_guid or str(uuid.uuid1())
+
+ #FIXME: Look for the bundle in the activity first (more specific)
+ #Look for the file in the path if a uid is supplied
+ if generated_guid:
+ #General store
+ store_path = os.path.join(_get_store_root(), str(generated_guid), INI_FILENAME)
+ if os.path.isfile(store_path):
+ self.Path = os.path.dirname(store_path)
+ elif _get_bundle_root() != None:
+ #Bundle store
+ bundle_path = os.path.join(_get_bundle_root(), str(generated_guid), INI_FILENAME)
+ if os.path.isfile(bundle_path):
+ self.Path = os.path.dirname(bundle_path)
+ else:
+ raise IOError(2,"Unable to locate metadata file for guid '%s'" % generated_guid)
+ else:
+ raise IOError(2,"Unable to locate metadata file for guid '%s'" % generated_guid)
+
+ else:
+ #Create the folder, any failure will go through to the caller for now
+ store_path = os.path.join(_get_store_root(), self.Guid)
+ os.makedirs(store_path)
+ self.Path = store_path
+
+ def write_metadata_file(self, tutorial):
+ """
+ Write metadata to the property file.
+ @param tutorial Tutorial for which to write metadata
+ """
+ #Create the Config Object and populate it
+ cfg = SafeConfigParser()
+ cfg.add_section(INI_METADATA_SECTION)
+ cfg.set(INI_METADATA_SECTION, INI_GUID_PROPERTY, self.Guid)
+ cfg.set(INI_METADATA_SECTION, INI_NAME_PROPERTY, tutorial.name)
+ cfg.set(INI_METADATA_SECTION, INI_XML_FSM_PROPERTY, TUTORIAL_FILENAME)
+ cfg.add_section(INI_ACTIVITY_SECTION)
+ if os.environ['SUGAR_BUNDLE_NAME'] != None and os.environ['SUGAR_BUNDLE_VERSION'] != None:
+ cfg.set(INI_ACTIVITY_SECTION, os.environ['SUGAR_BUNDLE_NAME'],
+ os.environ['SUGAR_BUNDLE_VERSION'])
+ else:
+ cfg.set(INI_ACTIVITY_SECTION, 'not_an_activity', '0')
+
+ #Write the ini file
+ cfg.write( file( os.path.join(self.Path, INI_FILENAME), 'w' ) )
+
+
+ @staticmethod
+ def get_tutorial_path(guid):
+ """
+ Finds the tutorial with the associated GUID. If it is found, return
+ the path to the tutorial's directory. If it doesn't exist, raise an
+ IOError.
+
+ A note : if there are two tutorials with this GUID in the folders,
+ they will both be inspected and the one with the highest version
+ number will be returned. If they have the same version number, the one
+ from the global store will be returned.
+
+ @param guid The GUID of the tutorial that is to be loaded.
+ """
+ # Attempt to find the tutorial's directory in the global directory
+ global_dir = os.path.join(_get_store_root(),str(guid))
+ # Then in the activty's bundle path
+ if _get_bundle_root() != None:
+ activity_dir = os.path.join(_get_bundle_root(), str(guid))
+ else:
+ activity_dir = ''
+
+ # If they both exist
+ if os.path.isdir(global_dir) and os.path.isdir(activity_dir):
+ # Inspect both metadata files
+ global_meta = os.path.join(global_dir, "meta.ini")
+ activity_meta = os.path.join(activity_dir, "meta.ini")
+
+ # Open both config files
+ global_parser = SafeConfigParser()
+ global_parser.read(global_meta)
+
+ activity_parser = SafeConfigParser()
+ activity_parser.read(activity_meta)
+
+ # Get the version number for each tutorial
+ global_version = global_parser.get(INI_METADATA_SECTION, "version")
+ activity_version = activity_parser.get(INI_METADATA_SECTION, "version")
+
+ # If the global version is higher or equal, we'll take it
+ if global_version >= activity_version:
+ return global_dir
+ else:
+ return activity_dir
+
+ # Do we just have the global directory?
+ if os.path.isdir(global_dir):
+ return global_dir
+
+ # Or just the activity's bundle directory?
+ if os.path.isdir(activity_dir):
+ return activity_dir
+
+ # Error : none of these directories contain the tutorial
+ raise IOError(2, "Neither the global nor the bundle directory contained the tutorial with GUID %s"%guid)
+
+
+ def write_fsm(self, fsm):
+
+ """
+ Save fsm to disk. If a GUID parameter is provided, the existing GUID is
+ located in the .ini files in the store root and bundle root and
+ the corresponding FSM is/are created or overwritten. If the GUID is not
+ found, an exception occur.
+ """
+
+ config = SafeConfigParser()
+
+ serializer = XMLSerializer()
+ path = os.path.join(self.Path, "meta.ini")
+ config.read(path)
+ xml_filename = config.get(INI_METADATA_SECTION, INI_XML_FSM_PROPERTY)
+ serializer.save_tutorial(fsm, xml_filename, self.Path)
+
diff --git a/tutorius/viewer.py b/tutorius/viewer.py
new file mode 100644
index 0000000..272558e
--- /dev/null
+++ b/tutorius/viewer.py
@@ -0,0 +1,423 @@
+# Copyright (C) 2009, Tutorius.org
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+This module renders a widget containing a graphical representation
+of a tutorial and acts as a creator proxy as it has some editing
+functionality.
+"""
+import sys
+
+import gtk, gtk.gdk
+import cairo
+from math import pi as PI
+PI2 = PI/2
+
+import rsvg
+
+from sugar.bundle import activitybundle
+from sugar.tutorius import addon
+from sugar.graphics import icon
+from sugar.tutorius.filters import EventFilter
+from sugar.tutorius.actions import Action
+import os
+
+# FIXME ideally, apps scale correctly and we should use proportional positions
+X_WIDTH = 800
+X_HEIGHT = 600
+ACTION_WIDTH = 100
+ACTION_HEIGHT = 70
+
+# block look
+BLOCK_PADDING = 5
+BLOCK_WIDTH = 100
+BLOCK_CORNERS = 10
+BLOCK_INNER_PAD = 10
+
+SNAP_WIDTH = BLOCK_WIDTH - BLOCK_PADDING - BLOCK_INNER_PAD*2
+SNAP_HEIGHT = SNAP_WIDTH*X_HEIGHT/X_WIDTH
+SNAP_SCALE = float(SNAP_WIDTH)/X_WIDTH
+
+class Viewer(object):
+ """
+ Renders a tutorial as a sequence of blocks, each block representing either
+ an action or an event (transition).
+
+ Current Viewer implementation lacks viewport management;
+ having many objects in a tutorial will not render properly.
+ """
+ def __init__(self, tutorial, creator):
+ super(Viewer, self).__init__()
+
+ self._tutorial = tutorial
+ self._creator = creator
+ self.alloc = None
+ self.click_pos = None
+ self.drag_pos = None
+ self.selection = []
+
+ self.win = gtk.Window(gtk.WINDOW_TOPLEVEL)
+ self.win.set_size_request(400, 200)
+ self.win.set_gravity(gtk.gdk.GRAVITY_SOUTH_WEST)
+ self.win.show()
+ self.win.set_deletable(False)
+ self.win.move(0, 0)
+
+ vbox = gtk.ScrolledWindow()
+ self.win.add(vbox)
+
+ canvas = gtk.DrawingArea()
+ vbox.add_with_viewport(canvas)
+ canvas.set_app_paintable(True)
+ canvas.connect_after("expose-event", self.on_viewer_expose, tutorial._states)
+ canvas.add_events(gtk.gdk.BUTTON_PRESS_MASK \
+ |gtk.gdk.BUTTON_MOTION_MASK \
+ |gtk.gdk.BUTTON_RELEASE_MASK \
+ |gtk.gdk.KEY_PRESS_MASK)
+ canvas.connect('button-press-event', self._on_click)
+ # drag-select disabled, for now
+ #canvas.connect('motion-notify-event', self._on_drag)
+ canvas.connect('button-release-event', self._on_drag_end)
+ canvas.connect('key-press-event', self._on_key_press)
+
+ canvas.set_flags(gtk.HAS_FOCUS|gtk.CAN_FOCUS)
+ canvas.grab_focus()
+
+ self.win.show_all()
+ canvas.set_size_request(2048, 180) # FIXME
+
+ def destroy(self):
+ self.win.destroy()
+
+
+ def _paint_state(self, ctx, states):
+ """
+ Paints a tutorius fsm state in a cairo context.
+ Final context state will be shifted by the size of the graphics.
+ """
+ block_width = BLOCK_WIDTH - BLOCK_PADDING
+ block_max_height = self.alloc.height
+
+ new_insert_point = None
+ cur_state = 'INIT'
+
+ # FIXME: get app when we have a model that supports it
+ cur_app = 'Calculate'
+ app_start = ctx.get_matrix()
+ try:
+ state = states[cur_state]
+ except KeyError:
+ state = None
+
+ while state:
+ new_app = 'Calculate'
+ if new_app != cur_app:
+ ctx.save()
+ ctx.set_matrix(app_start)
+ self._render_app_hints(ctx, cur_app)
+ ctx.restore()
+ app_start = ctx.get_matrix()
+ ctx.translate(BLOCK_PADDING, 0)
+ cur_app = new_app
+
+ action_list = state.get_action_list()
+ if action_list:
+ local_height = (block_max_height - BLOCK_PADDING)/len(action_list) - BLOCK_PADDING
+ ctx.save()
+ for action in action_list:
+ origin = tuple(ctx.get_matrix())[-2:]
+ if self.click_pos and \
+ self.click_pos[0]-BLOCK_WIDTH<origin[0] and \
+ self.drag_pos[0]>origin[0]:
+ self.selection.append(action)
+ self.render_action(ctx, block_width, local_height, action)
+ ctx.translate(0, local_height+BLOCK_PADDING)
+
+ ctx.restore()
+ ctx.translate(BLOCK_WIDTH, 0)
+
+ # insertion cursor painting made from two opposed triangles
+ # joined by a line.
+ if state.name == self._creator.get_insertion_point():
+ ctx.save()
+ bp2 = BLOCK_PADDING/2
+ ctx.move_to(-bp2, 0)
+ ctx.line_to(-BLOCK_PADDING-bp2, -BLOCK_PADDING)
+ ctx.line_to(bp2, -BLOCK_PADDING)
+ ctx.line_to(-bp2, 0)
+
+ ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING)
+ ctx.line_to(bp2, block_max_height-BLOCK_PADDING)
+ ctx.line_to(-BLOCK_PADDING-bp2, block_max_height-BLOCK_PADDING)
+ ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING)
+
+ ctx.line_to(-bp2, BLOCK_PADDING)
+ ctx.set_source_rgb(1.0, 1.0, 0.0)
+ ctx.stroke_preserve()
+ ctx.fill()
+ ctx.restore()
+
+
+ event_list = state.get_event_filter_list()
+ if event_list:
+ local_height = (block_max_height - BLOCK_PADDING)/len(event_list) - BLOCK_PADDING
+ ctx.save()
+ for event, next_state in event_list:
+ origin = tuple(ctx.get_matrix())[-2:]
+ if self.click_pos and \
+ self.click_pos[0]-BLOCK_WIDTH<origin[0] and \
+ self.drag_pos[0]>origin[0]:
+ self.selection.append(event)
+ self.render_event(ctx, block_width, local_height, event)
+ ctx.translate(0, local_height+BLOCK_PADDING)
+
+ ctx.restore()
+ ctx.translate(BLOCK_WIDTH, 0)
+
+ # FIXME point to next state in state, as it would highlight
+ # the "happy path".
+ cur_state = event_list[0][1]
+
+ if (not new_insert_point) and self.click_pos:
+ origin = tuple(ctx.get_matrix())[-2:]
+ if self.click_pos[0]<origin[0]:
+ new_insert_point = state
+
+ if event_list:
+ try:
+ state = states[cur_state]
+ except KeyError:
+ break
+ yield True
+ else:
+ break
+
+ ctx.set_matrix(app_start)
+ self._render_app_hints(ctx, cur_app)
+
+ if self.click_pos:
+ if not new_insert_point:
+ new_insert_point = state
+
+ self._creator.set_insertion_point(new_insert_point.name)
+
+ yield False
+
+ def _render_snapshot(self, ctx, elem):
+ """
+ Render the "simplified screenshot-like" representation of elements positions.
+ """
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.5)
+ ctx.rectangle(0, 0, SNAP_WIDTH, SNAP_HEIGHT)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ if hasattr(elem, 'position'):
+ pos = elem.position
+ # FIXME this size approximation is fine, but I believe we could
+ # do better.
+ ctx.scale(SNAP_SCALE, SNAP_SCALE)
+ ctx.rectangle(pos[0], pos[1], ACTION_WIDTH, ACTION_HEIGHT)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ def _render_app_hints(self, ctx, appname):
+ """
+ Fetches the icon of the app related to current states and renders it on a
+ separator, between states.
+ """
+ ctx.set_source_rgb(0.0, 0.0, 0.0)
+ ctx.set_dash((1,1,0,0), 1)
+ ctx.move_to(0, 0)
+ ctx.line_to(0, self.alloc.height)
+ ctx.stroke()
+ ctx.set_dash(tuple(), 1)
+
+ bundle_path = os.getenv("SUGAR_BUNDLE_PATH")
+ if bundle_path:
+ icon_path = activitybundle.ActivityBundle(bundle_path).get_icon()
+ icon = rsvg.Handle(icon_path)
+ ctx.save()
+ ctx.translate(-15, 0)
+ ctx.scale(0.5, 0.5)
+ icon_surf = icon.render_cairo(ctx)
+ ctx.restore()
+
+
+ def render_action(self, ctx, width, height, action):
+ """
+ Renders the action block, along with the icon of the action tool.
+ """
+ ctx.save()
+ inner_width = width-(BLOCK_CORNERS<<1)
+ inner_height = height-(BLOCK_CORNERS<<1)
+
+ paint_border = ctx.rel_line_to
+ filling = cairo.LinearGradient(0, 0, 0, inner_height)
+ if action not in self.selection:
+ filling.add_color_stop_rgb(0.0, 0.7, 0.7, 1.0)
+ filling.add_color_stop_rgb(1.0, 0.1, 0.1, 0.8)
+ else:
+ filling.add_color_stop_rgb(0.0, 0.4, 0.4, 0.8)
+ filling.add_color_stop_rgb(1.0, 0.0, 0.0, 0.5)
+ tracing = cairo.LinearGradient(0, 0, 0, inner_height)
+ tracing.add_color_stop_rgb(0.0, 1.0, 1.0, 1.0)
+ tracing.add_color_stop_rgb(1.0, 0.2, 0.2, 0.2)
+
+ ctx.move_to(BLOCK_CORNERS, 0)
+ paint_border(inner_width, 0)
+ ctx.arc(inner_width+BLOCK_CORNERS, BLOCK_CORNERS, BLOCK_CORNERS, -PI2, 0.0)
+ ctx.arc(inner_width+BLOCK_CORNERS, inner_height+BLOCK_CORNERS, BLOCK_CORNERS, 0.0, PI2)
+ ctx.arc(BLOCK_CORNERS, inner_height+BLOCK_CORNERS, BLOCK_CORNERS, PI2, PI)
+ ctx.arc(BLOCK_CORNERS, BLOCK_CORNERS, BLOCK_CORNERS, -PI, -PI2)
+
+ ctx.set_source(tracing)
+ ctx.stroke_preserve()
+ ctx.set_source(filling)
+ ctx.fill()
+
+ addon_name = addon.get_name_from_type(type(action))
+ # TODO use icon pool
+ icon_name = addon.get_addon_meta(addon_name)['icon']
+ rsvg_icon = rsvg.Handle(icon.get_icon_file_name(icon_name))
+ ctx.save()
+ ctx.translate(BLOCK_INNER_PAD, BLOCK_INNER_PAD)
+ ctx.scale(0.5, 0.5)
+ icon_surf = rsvg_icon.render_cairo(ctx)
+
+ ctx.restore()
+
+ ctx.translate(BLOCK_INNER_PAD, (height-SNAP_HEIGHT)/2)
+ self._render_snapshot(ctx, action)
+
+ ctx.restore()
+
+ def render_event(self, ctx, width, height, event):
+ """
+ Renders the action block, along with the icon of the action tool.
+ """
+ ctx.save()
+ inner_width = width-(BLOCK_CORNERS<<1)
+ inner_height = height-(BLOCK_CORNERS<<1)
+
+ filling = cairo.LinearGradient(0, 0, 0, inner_height)
+ if event not in self.selection:
+ filling.add_color_stop_rgb(0.0, 1.0, 0.8, 0.6)
+ filling.add_color_stop_rgb(1.0, 1.0, 0.6, 0.2)
+ else:
+ filling.add_color_stop_rgb(0.0, 0.8, 0.6, 0.4)
+ filling.add_color_stop_rgb(1.0, 0.6, 0.4, 0.1)
+ tracing = cairo.LinearGradient(0, 0, 0, inner_height)
+ tracing.add_color_stop_rgb(0.0, 1.0, 1.0, 1.0)
+ tracing.add_color_stop_rgb(1.0, 0.3, 0.3, 0.3)
+
+ ctx.move_to(BLOCK_CORNERS, 0)
+ ctx.rel_line_to(inner_width, 0)
+ ctx.rel_line_to(BLOCK_CORNERS, BLOCK_CORNERS)
+ ctx.rel_line_to(0, inner_height)
+ ctx.rel_line_to(-BLOCK_CORNERS, BLOCK_CORNERS)
+ ctx.rel_line_to(-inner_width, 0)
+ ctx.rel_line_to(-BLOCK_CORNERS, -BLOCK_CORNERS)
+ ctx.rel_line_to(0, -inner_height)
+ ctx.close_path()
+
+ ctx.set_source(tracing)
+ ctx.stroke_preserve()
+ ctx.set_source(filling)
+ ctx.fill()
+
+ addon_name = addon.get_name_from_type(type(event))
+ # TODO use icon pool
+ icon_name = addon.get_addon_meta(addon_name)['icon']
+ rsvg_icon = rsvg.Handle(icon.get_icon_file_name(icon_name))
+ ctx.save()
+ ctx.translate(BLOCK_INNER_PAD, BLOCK_INNER_PAD)
+ ctx.scale(0.5, 0.5)
+ icon_surf = rsvg_icon.render_cairo(ctx)
+
+ ctx.restore()
+
+ ctx.translate(BLOCK_INNER_PAD, (height-SNAP_HEIGHT)/2)
+ self._render_snapshot(ctx, event)
+
+ ctx.restore()
+
+ def on_viewer_expose(self, widget, evt, states):
+ """
+ Expose signal handler for the viewer's DrawingArea.
+ This loops through states and renders every action and transition of
+ the "happy path".
+
+ @param widget: the gtk.DrawingArea on which to draw
+ @param evt: the gtk.gdk.Event containing an "expose" event
+ @param states: a tutorius FiniteStateMachine object to paint
+ """
+ ctx = widget.window.cairo_create()
+ self.alloc = widget.get_allocation()
+ ctx.set_source_pixmap(widget.window,
+ widget.allocation.x,
+ widget.allocation.y)
+
+ # draw no more than our expose event intersects our child
+ region = gtk.gdk.region_rectangle(widget.allocation)
+ r = gtk.gdk.region_rectangle(evt.area)
+ region.intersect(r)
+ ctx.region (region)
+ ctx.clip()
+ ctx.paint()
+
+ ctx.translate(BLOCK_PADDING, BLOCK_PADDING)
+
+ painter = self._paint_state(ctx, states)
+ while painter.next(): pass
+
+ if self.click_pos and self.drag_pos:
+ ctx.set_matrix(cairo.Matrix())
+ ctx.rectangle(self.click_pos[0], self.click_pos[1],
+ self.drag_pos[0]-self.click_pos[0],
+ self.drag_pos[1]-self.click_pos[1])
+ ctx.set_source_rgba(0, 0, 1, 0.5)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ return False
+
+ def _on_click(self, widget, evt):
+ # the rendering pipeline will work out the click validation process
+ self.drag_pos = None
+ self.drag_pos = self.click_pos = evt.get_coords()
+ widget.queue_draw()
+
+ self.selection = []
+
+ def _on_drag(self, widget, evt):
+ self.drag_pos = evt.get_coords()
+ widget.queue_draw()
+
+ def _on_drag_end(self, widget, evt):
+ self.click_pos = self.drag_pos = None
+ widget.queue_draw()
+
+ def _on_key_press(self, widget, evt):
+ if evt.keyval == gtk.keysyms.BackSpace:
+ # remove selection
+ for selected in self.selection:
+ if isinstance(selected, EventFilter):
+ self._creator.delete_state()
+ else:
+ self._creator.delete_action(selected)
+ widget.queue_draw()
+
+