From b4e9ca55fc02458a9df04fa7df4d882b79d752be Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 21 Oct 2009 04:34:27 +0000 Subject: Merge branch 'master' of git://git.sugarlabs.org/tutorius/mainline --- diff --git a/addons/bubblemessage.py b/addons/bubblemessage.py index c499bdb..2bd2d31 100644 --- a/addons/bubblemessage.py +++ b/addons/bubblemessage.py @@ -13,14 +13,17 @@ # 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.actions import Action, DragWrapper +from sugar.tutorius.properties import TStringProperty, TArrayProperty +from sugar.tutorius import overlayer +from sugar.tutorius.services import ObjectStore class BubbleMessage(Action): message = TStringProperty("Message") # Create the position as an array of fixed-size 2 - position = TArrayProperty([0,0], 2, 2) + position = TArrayProperty((0,0), 2, 2) # Do the same for the tail position - tail_pos = TArrayProperty([0,0], 2, 2) + tail_pos = TArrayProperty((0,0), 2, 2) def __init__(self, message=None, position=None, speaker=None, tail_pos=None): """ @@ -94,7 +97,7 @@ class BubbleMessage(Action): def exit_editmode(self, *args): x,y = self._drag.position - self.position = [int(x), int(y)] + self.position = (int(x), int(y)) if self._drag: self._drag.draggable = False self._drag = None diff --git a/addons/chainaction.py b/addons/chainaction.py new file mode 100644 index 0000000..43c4fa4 --- /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 sugar.tutorius.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..828dd75 --- /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 sugar.tutorius import gtkutils +from sugar.tutorius.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 298466a..f15f256 100644 --- a/addons/dialogmessage.py +++ b/addons/dialogmessage.py @@ -20,7 +20,7 @@ from sugar.tutorius.actions import * class DialogMessage(Action): message = TStringProperty("Message") - position = TArrayProperty([0, 0], 2, 2) + position = TArrayProperty((0, 0), 2, 2) def __init__(self, message=None, position=None): """ diff --git a/addons/disablewidget.py b/addons/disablewidget.py new file mode 100644 index 0000000..ce3f235 --- /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 sugar.tutorius.actions import * +from sugar.tutorius import gtkutils +from sugar.tutorius.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/gtkwidgeteventfilter.py b/addons/gtkwidgeteventfilter.py index cbfb00c..5497af4 100644 --- a/addons/gtkwidgeteventfilter.py +++ b/addons/gtkwidgeteventfilter.py @@ -13,8 +13,9 @@ # 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 * -from sugar.tutorius.properties import * +from sugar.tutorius.filters import EventFilter +from sugar.tutorius.properties import TUAMProperty, TStringProperty +from sugar.tutorius.gtkutils import find_widget class GtkWidgetEventFilter(EventFilter): """ @@ -23,13 +24,12 @@ class GtkWidgetEventFilter(EventFilter): object_id = TUAMProperty() event_name = TStringProperty("clicked") - def __init__(self, next_state=None, object_id=None, event_name=None): + def __init__(self, object_id=None, event_name=None): """Constructor - @param next_state default EventFilter param, passed on to EventFilter @param object_id object fqdn-style identifier @param event_name event to attach to """ - super(GtkWidgetEventFilter,self).__init__(next_state) + super(GtkWidgetEventFilter,self).__init__() self._callback = None self.object_id = object_id self.event_name = event_name diff --git a/addons/gtkwidgettypefilter.py b/addons/gtkwidgettypefilter.py new file mode 100644 index 0000000..16673c1 --- /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 sugar.tutorius.filters import * +from sugar.tutorius.properties import * +from sugar.tutorius.services import ObjectStore +from sugar.tutorius.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, next_state, 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__(next_state) + 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..97f4752 --- /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 sugar.tutorius.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 new file mode 100644 index 0000000..0d276b9 --- /dev/null +++ b/addons/readfile.py @@ -0,0 +1,56 @@ +# 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 + +from sugar.tutorius.actions import Action +from sugar.tutorius.properties import TFileProperty +from sugar.tutorius.services import ObjectStore + +class ReadFile(Action): + filename = TFileProperty(None) + + def __init__(self, filename=None): + """ + Calls activity.read_file to restore a specified state to an activity + like when restored from the journal. + @param filename Path to the file to read + """ + Action.__init__(self) + + if filename: + self.filename=filename + + def do(self): + """ + Perform the action, call read_file on the activity + """ + if os.path.isfile(str(self.filename)): + ObjectStore().activity.read_file(self.filename) + + def undo(self): + """ + Not undoable + """ + pass + +__action__ = { + "name" : "ReadFile", + "display_name" : "Read File", + "icon" : "message-bubble", #FIXME + "class" : ReadFile, + "mandatory_props" : ["filename"] +} diff --git a/addons/timerevent.py b/addons/timerevent.py new file mode 100644 index 0000000..c7374d0 --- /dev/null +++ b/addons/timerevent.py @@ -0,0 +1,73 @@ +# 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 gobject + +from sugar.tutorius.filters import EventFilter +from sugar.tutorius.properties import TIntProperty + +class TimerEvent(EventFilter): + """ + TimerEvent is a special EventFilter that uses gobject + timeouts to trigger a state change after a specified amount + 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 timeout in seconds + """ + super(TimerEvent,self).__init__() + if timeout: + self.timeout = timeout + self._handler_id = None + + def install_handlers(self, callback, **kwargs): + """install_handlers creates the timer and starts it""" + super(TimerEvent,self).install_handlers(callback, **kwargs) + #Create the timer + self._handler_id = gobject.timeout_add_seconds(self.timeout, self._timeout_cb) + + def remove_handlers(self): + """remove handler removes the timer""" + super(TimerEvent,self).remove_handlers() + if self._handler_id: + try: + #XXX What happens if this was already triggered? + #remove the timer + gobject.source_remove(self._handler_id) + except: + pass + + def _timeout_cb(self): + """ + _timeout_cb triggers the eventfilter callback. + + It is necessary because gobject timers only stop if the callback they + trigger returns False + """ + self.do_callback() + return False #Stops timeout + +__event__ = { + "name" : "TimerEvent", + "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..06c0995 --- /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 sugar.tutorius.filters import * +from sugar.tutorius.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, 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 + +__event__ = { + 'name' : 'TriggerEventFilter', + 'display_name' : 'Triggerable event filter (test only)', + 'icon' : '', + 'class' : TriggerEventFilter, + 'mandatory_props' : ['next_state'], + 'test' : True +} diff --git a/addons/typetextaction.py b/addons/typetextaction.py new file mode 100644 index 0000000..fee66e5 --- /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 sugar.tutorius.actions import * +from sugar.tutorius 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..3c66211 --- /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 sugar.tutorius.actions import * + +from sugar.tutorius.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/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 +# +# +# 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/coretests.py b/tests/coretests.py index f90374f..4f564c8 100644 --- a/tests/coretests.py +++ b/tests/coretests.py @@ -28,6 +28,7 @@ and event filters. Those are in their separate test module import unittest +import copy import logging from sugar.tutorius.actions import * from sugar.tutorius.addon import * @@ -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. @@ -159,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 @@ -201,7 +221,80 @@ 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", "nextState", "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) + + # Build the second state + st2.add_action(act2) + st2.add_action(act3) + st2.add_event_filter(event1) + + # 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 = copy.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 = copy.deepcopy(st1) + st4.add_event_filter(addon.create("GtkWidgetEventFilter", "next_state", "0.0.1.1.2.2.3", "clicked")) + + assert not (st1 == st4), "States having a different number of events should be different" + + st5 = copy.deepcopy(st1) + st5._event_filters = [] + + st5.add_event_filter(addon.create("GtkWidgetEventFilter", "other_state", "0.1.2.3.4.1.2", "pressed")) + + #import rpdb2; rpdb2.start_embedded_debugger('pass') + 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 @@ -246,6 +339,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. @@ -337,10 +431,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): @@ -367,8 +461,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 = copy.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 = copy.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): @@ -425,6 +627,5 @@ class FSMExplorationTests(unittest.TestCase): self.validate_previous_states("Fourth", ("Second")) - if __name__ == "__main__": unittest.main() diff --git a/tests/probetests.py b/tests/probetests.py new file mode 100644 index 0000000..a440334 --- /dev/null +++ b/tests/probetests.py @@ -0,0 +1,63 @@ +# 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 +""" +Probe Tests + +""" + +import unittest +import os, sys +import gtk +import time + +from dbus.mainloop.glib import DBusGMainLoop +import dbus + +from sugar.tutorius.TProbe import TProbe, ProbeProxy + + +class FakeActivity(object): + def __init__(self): + self.top = gtk.Window(type=gtk.WINDOW_TOPLEVEL) + self.top.set_name("Top") + + hbox = gtk.HBox() + self.top.add(hbox) + hbox.show() + + btn1 = gtk.Button() + btn1.set_name("Button1") + hbox.pack_start(btn1) + btn1.show() + self.button = btn1 + +class ProbeTest(unittest.TestCase): + def test_ping(self): + m = DBusGMainLoop(set_as_default=True) + dbus.set_default_main_loop(m) + + activity = FakeActivity() + probe = TProbe("localhost.unittest.ProbeTest", activity.top) + + #Parent, ping the probe + proxy = ProbeProxy("localhost.unittest.ProbeTest") + res = probe.ping() + + assert res == "alive", "Probe should be alive" + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/propertiestests.py b/tests/propertiestests.py index e1f6f4b..0b8251a 100644 --- a/tests/propertiestests.py +++ b/tests/propertiestests.py @@ -17,6 +17,7 @@ import unittest import uuid import os +import copy from sugar.tutorius.constraints import * from sugar.tutorius.properties import * @@ -83,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): @@ -379,12 +501,18 @@ 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 = "sample_file1_" + str(uuid.uuid1()) + ".txt" + 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 = "sample_file2_" + str(uuid.uuid1()) + ".txt" + 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() @@ -412,6 +540,45 @@ 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 if __name__ == "__main__": unittest.main() diff --git a/tests/run-tests.py b/tests/run-tests.py deleted file mode 100755 index d41aa0a..0000000 --- a/tests/run-tests.py +++ /dev/null @@ -1,74 +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 - -INSTALL_PATH="../../../../../../install/lib/python2.5/site-packages/" - -import os, sys -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 - 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)) - 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 * - - unittest.main() diff --git a/tests/serializertests.py b/tests/serializertests.py index c939b7a..2f2e287 100644 --- a/tests/serializertests.py +++ b/tests/serializertests.py @@ -164,7 +164,7 @@ class XMLSerializerTest(unittest.TestCase): self.test_save() reloaded_fsm = xml_ser.load_fsm(str(self.uuid)) - assert self.fsm.is_identical(reloaded_fsm), "Expected equivalence before saving vs after loading." + assert self.fsm == reloaded_fsm, "Expected equivalence before saving vs after loading." def test_all_filters(self): """ @@ -190,7 +190,7 @@ class XMLSerializerTest(unittest.TestCase): reloaded_fsm = xml_ser.load_fsm(str(self.uuid)) - assert self.fsm.is_identical(reloaded_fsm), "Expected equivalence before saving vs after loading." + assert self.fsm == reloaded_fsm, "Expected equivalence before saving vs after loading." if __name__ == "__main__": unittest.main() diff --git a/tests/storetests.py b/tests/storetests.py new file mode 100644 index 0000000..da20c00 --- /dev/null +++ b/tests/storetests.py @@ -0,0 +1,107 @@ +# 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 sugar.tutorius.store import * + +g_tutorial_id = '114db454-b2a1-11de-8cfc-001f5bf747dc' +g_other_id = '47efc6ee-b2a3-11de-8cfc-001f5bf747dc' + +class StoreProxyTest(unittest.TestCase): + def setUp(self): + self.store = StoreProxy() + + def tearDown(self): + pass + + def test_get_categories(self): + categories = self.store.get_categories() + + assert isinstance(categories, list), "categories should be a list" + + def test_get_tutorials(self): + self.store.get_tutorials() + + def test_get_tutorial_collection(self): + collection_list = self.store.get_tutorial_collection('top5_rating') + + assert isinstance(collection_list, list), "get_tutorial_collection should return a list" + + def test_get_latest_version(self): + version_dict = self.store.get_latest_version([]) + + assert isinstance(version_dict, dict) + + def test_download_tutorial(self): + tutorial = self.store.download_tutorial(g_tutorial_id) + + assert tutorial is not None + + def test_login(self): + assert self.store.login("unknown_user", "random_password") + + def test_register_new_user(self): + user_info = { + 'name' : "Albert", + 'last_name' : "The Tester", + 'location' : 'Mozambique', + 'email' : 'albertthetester@mozambique.org' + } + + assert self.store.register_new_user(user_info) + + +class StoreProxyLoginTest(unittest.TestCase): + def setUp(self): + self.store = StoreProxy() + self.store.login("unknown_user", "random_password") + + def tearDown(self): + session_id = self.store.get_session_id() + + if session_id is not None: + self.store.close_session() + + def test_close_session(self): + assert self.store.close_session() + + def test_get_session_id(self): + session_id = self.store.get_session_id() + + assert session_id is not None + + def test_rate(self): + assert self.store.rate(5, g_tutorial_id) + + def test_publish(self): + # TODO : We need to send in a real tutorial loaded from + # the Vault + assert self.store.publish(['This should be a real tutorial...']) + + def test_unpublish(self): + # TODO : We need to send in a real tutorial loaded from + # the Vault + self.store.publish([g_tutorial_id, 'Fake tutorial']) + + assert self.store.unpublish(g_tutorial_id) + + def test_update_published_tutorial(self): + # TODO : Run these tests with files from the Vault + self.store.publish([g_tutorial_id, 'Fake tutorial']) + + assert self.store.update_published_tutorial(g_tutorial_id, [g_tutorial_id, 'This is an updated tutorial']) + diff --git a/tutorius/TProbe.py b/tutorius/TProbe.py new file mode 100644 index 0000000..6d7b6e2 --- /dev/null +++ b/tutorius/TProbe.py @@ -0,0 +1,506 @@ +import logging +LOGGER = logging.getLogger("sugar.tutorius.TProbe") +import os + +import gobject + +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 sugar.tutorius.dbustools import remote_call, save_args +import copy + +""" + -------------------- + | ProbeManager | + -------------------- + | + V + -------------------- ---------- + | ProbeProxy |<---- DBus ---->| TProbe | + -------------------- ---------- + +""" +#TODO Add stub error handling for remote calls in the classes so that it will +# be clearer how errors can be handled in the future. + + +class TProbe(dbus.service.Object): + """ Tutorius Probe + Defines an entry point for Tutorius into activities that allows + performing actions and registering events onto an activity via + a DBUS Interface. + """ + + def __init__(self, activity_name, activity): + """ + Create and register a TProbe for an activity. + + @param activity_name unique activity_id + @param activity activity reference, must be a gtk container + """ + 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 + + ObjectStore().activity = activity + + self._activity_name = activity_name + 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") + + # Add the dictionary we will use to store which actions and events + # are known + self._installedActions = {} + self._subscribedEvents = {} + + 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() + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='') + def registered(self, service): + print ("Registered with: " + str(service)) + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='', out_signature='s') + def ping(self): + """ + Allows testing the connection to a Probe + @return string "alive" + """ + return "alive" + + # ------------------ Action handling -------------------------------------- + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='s') + def install(self, pickled_action): + """ + Install an action on the Activity + @param pickled_action string pickled action + @return string address of installed action + """ + loaded_action = pickle.loads(str(pickled_action)) + action = addon.create(loaded_action.__class__.__name__) + + address = self._generate_action_reference(action) + + self._installedActions[address] = action + + if action._props: + action._props.update(loaded_action._props) + + action.do() + + return address + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='ss', out_signature='') + def update(self, address, action_props): + """ + Update an already registered action + @param address string address returned by install() + @param action_props pickled action properties + @return None + """ + action = self._installedActions[address] + + if action._props: + props = pickle.loads(str(action_props)) + action._props.update(props) + action.undo() + action.do() + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='') + def uninstall(self, address): + """ + Uninstall an action + @param address string address returned by install() + @return None + """ + if self._installedActions.has_key(address): + action = self._installedActions[address] + action.undo() + self._installedActions.pop(address) + + + # ------------------ Event handling --------------------------------------- + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='s') + def subscribe(self, pickled_event): + """ + Subscribe to an Event + @param pickled_event string pickled EventFilter + @return string unique name of registered event + """ + #TODO Perform event unmapping once Tutorials use abstract events + # instead of concrete EventFilters that are tied to their + # implementation. + eventfilter = pickle.loads(str(pickled_event)) + + # The callback uses the event defined previously and each + # successive call to subscribe will register a different + # callback that references a different event + def callback(*args): + self.notify(eventfilter) + + eventfilter.install_handlers(callback, activity=self._activity) + + name = self._generate_event_reference(eventfilter) + self._subscribedEvents[name] = eventfilter + + return name + + @dbus.service.method("org.tutorius.ProbeInterface", + in_signature='s', out_signature='') + def unsubscribe(self, address): + """ + Remove subscription to an event + @param address string adress returned by subscribe() + @return None + """ + + if self._subscribedEvents.has_key(address): + eventfilter = self._subscribedEvents[address] + eventfilter.remove_handlers() + self._subscribedEvents.pop(address) + + @dbus.service.signal("org.tutorius.ProbeInterface") + def eventOccured(self, event): + # We need no processing now, the signal will be sent + # when the method exit + pass + + # 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)) + + # Return a unique name for this action + def _generate_action_reference(self, action): + # TODO elavoie 2009-07-25 Should return a universal address + name = action.__class__.__name__ + suffix = 1 + + while self._installedActions.has_key(name+str(suffix)): + suffix += 1 + + return name + str(suffix) + + + # Return a unique name for this event + def _generate_event_reference(self, event): + # TODO elavoie 2009-07-25 Should return a universal address + name = event.__class__.__name__ + #Keep the counter to avoid looping all the time + suffix = getattr(self, '_event_ref_suffix', 0 ) + 1 + + while self._subscribedEvents.has_key(name+str(suffix)): + suffix += 1 + + #setattr(self, '_event_ref_suffix', suffix) + + return name + str(suffix) + +class ProbeProxy: + """ + ProbeProxy is a Proxy class for connecting to a remote TProbe. + + It provides an object interface to the TProbe, which requires pickled + strings, across a DBus communication. + """ + def __init__(self, activityName): + """ + Constructor + @param activityName unique activity id. Must be a valid dbus bus name. + """ + 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._probe = dbus.Interface(self._object, "org.tutorius.ProbeInterface") + + self._actions = {} + # We keep those two data structures to be able to have multiple callbacks + # for the same event and be able to remove them independently + # _subscribedEvents holds a list of callback addresses's for each event + # _registeredCallbacks holds the functions to call for each address + self._subscribedEvents = {} + self._registeredCallbacks = {} + + + self._object.connect_to_signal("eventOccured", self._handle_signal, dbus_interface="org.tutorius.ProbeInterface") + + def _handle_signal(self, pickled_event): + event = pickle.loads(str(pickled_event)) + LOGGER.debug("ProbeProxy :: Received Event : %s %s", str(event), str(event._props.items())) + + LOGGER.debug("ProbeProxy :: Currently %d events registered", len(self._registeredCallbacks)) + if self._registeredCallbacks.has_key(event): + for callback in self._registeredCallbacks[event].values(): + callback(event) + else: + for event in self._registeredCallbacks.keys(): + LOGGER.debug("==== %s", str(event._props.items())) + LOGGER.debug("ProbeProxy :: Event does not appear to be registered") + + def isAlive(self): + try: + return self._probe.ping() == "alive" + except: + 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): + self._actions.pop(action, None) + + def install(self, action, block=False): + """ + Install an action on the TProbe's activity + @param action Action to install + @param block Force a synchroneous dbus call if True + @return None + """ + return remote_call(self._probe.install, (pickle.dumps(action),), + save_args(self.__update_action, action), + block=block) + + def update(self, action, newaction, block=False): + """ + Update an already installed action's properties and run it again + @param action Action to update + @param newaction Action to update it with + @param block Force a synchroneous dbus call if True + @return None + """ + #TODO review how to make this work well + if not action in self._actions: + raise RuntimeWarning("Action not installed") + #TODO Check error handling + return remote_call(self._probe.update, (self._actions[action], pickle.dumps(newaction._props)), block=block) + + def uninstall(self, action, block=False): + """ + Uninstall an installed action + @param action Action to uninstall + @param block Force a synchroneous dbus call if True + """ + if action in self._actions: + remote_call(self._probe.uninstall,(self._actions.pop(action),), block=block) + + def __update_event(self, event, callback, address): + LOGGER.debug("ProbeProxy :: Registered event %s with address %s", str(hash(event)), str(address)) + # Since multiple callbacks could be associated to the same + # event signature, we will store multiple callbacks + # in a dictionary indexed by the unique address + # given for this subscribtion and access this + # dictionary from another one indexed by event + address = str(address) + + # We use the event object as a key + if not self._registeredCallbacks.has_key(event): + self._registeredCallbacks[event] = {} + + # TODO elavoie 2009-07-25 decide on a proper exception + # taxonomy + if self._registeredCallbacks[event].has_key(address): + # Oups, how come we have two similar addresses? + # send the bad news! + raise Exception("Probe subscribe exception, the following address already exists: " + str(address)) + + self._registeredCallbacks[event][address] = callback + + # We will keep another dictionary to remember the + # event that was associated to this unique address + # Let's copy to make sure that even if the event + # passed in is modified later it won't screw up + # our dictionary (python pass arguments by reference) + self._subscribedEvents[address] = copy.copy(event) + + return address + + def __clear_event(self, address): + LOGGER.debug("ProbeProxy :: Unregistering adress %s", str(address)) + # Cleanup everything + if self._subscribedEvents.has_key(address): + event = self._subscribedEvents[address] + + if self._registeredCallbacks.has_key(event)\ + and self._registeredCallbacks[event].has_key(address): + self._registeredCallbacks[event].pop(address) + + if self._registeredCallbacks[event] == {}: + self._registeredCallbacks.pop(event) + + self._subscribedEvents.pop(address) + else: + LOGGER.debug("ProbeProxy :: unsubsribe address %s inconsistency : not registered", address) + + def subscribe(self, event, callback, block=True): + """ + Register an event listener + @param event Event to listen for + @param callback callable that will be called when the event occurs + @param block Force a synchroneous dbus call if True (Not allowed yet) + @return address identifier used for unsubscribing + """ + LOGGER.debug("ProbeProxy :: Registering event %s", str(hash(event))) + if not block: + raise RuntimeError("This function does not allow non-blocking mode yet") + + # TODO elavoie 2009-07-25 When we will allow for patterns both + # for event types and sources, we will need to revise the lookup + # mecanism for which callback function to call + return remote_call(self._probe.subscribe, (pickle.dumps(event),), + save_args(self.__update_event, event, callback), + block=block) + + def unsubscribe(self, address, block=True): + """ + Unregister an event listener + @param address identifier given by subscribe() + @param block Force a synchroneous dbus call if True + @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) + + 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 address in self._subscribedEvents.keys(): + self.unsubscribe(address, block) + + +class ProbeManager(object): + """ + The ProbeManager provides multiplexing across multiple activity ProbeProxies + + 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): + self._probes = {} + self._current_activity = None + + def setCurrentActivity(self, activity_id): + if not activity_id in self._probes: + raise RuntimeError("Activity not attached") + self._current_activity = activity_id + + def getCurrentActivity(self): + 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): + """ + Install an action on the current activity + @param action Action to install + @param block Force a synchroneous dbus call if True + @return None + """ + if self.currentActivity: + return self._probes[self.currentActivity].install(action, block) + else: + raise RuntimeWarning("No activity attached") + + def update(self, action, newaction, block=False): + """ + Update an already installed action's properties and run it again + @param action Action to update + @param newaction Action to update it with + @param block Force a synchroneous dbus call if True + @return None + """ + if self.currentActivity: + return self._probes[self.currentActivity].update(action, newaction, block) + else: + raise RuntimeWarning("No activity attached") + + def uninstall(self, action, block=False): + """ + Uninstall an installed action + @param action Action to uninstall + @param block Force a synchroneous dbus call if True + """ + if self.currentActivity: + return self._probes[self.currentActivity].uninstall(action, block) + else: + raise RuntimeWarning("No activity attached") + + def subscribe(self, event, callback): + """ + Register an event listener + @param event Event to listen for + @param callback callable that will be called when the event occurs + @return address identifier used for unsubscribing + """ + if self.currentActivity: + return self._probes[self.currentActivity].subscribe(event, callback) + else: + raise RuntimeWarning("No activity attached") + + def unsubscribe(self, address): + """ + Unregister an event listener + @param address identifier given by subscribe() + @return None + """ + if self.currentActivity: + return self._probes[self.currentActivity].unsubscribe(address) + else: + raise RuntimeWarning("No activity attached") + diff --git a/tutorius/actions.py b/tutorius/actions.py index 0db7988..08f55cd 100644 --- a/tutorius/actions.py +++ b/tutorius/actions.py @@ -16,16 +16,14 @@ """ 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 import addon from sugar.tutorius.services import ObjectStore from sugar.tutorius.properties import * from sugar.graphics import icon -import gtk.gdk class DragWrapper(object): """Wrapper to allow gtk widgets to be dragged around""" @@ -177,148 +175,3 @@ class Action(TPropContainer): 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 e311a65..15612c8 100644 --- a/tutorius/addon.py +++ b/tutorius/addon.py @@ -62,7 +62,6 @@ def create(name, *args, **kwargs): except: logging.error("Could not instantiate %s with parameters %s, %s"%(comp_metadata['name'],str(args), str(kwargs))) return None - return _cache[name]['class'](*args, **kwargs) except KeyError: logging.error("Addon not found for class '%s'", name) return None diff --git a/tutorius/bundler.py b/tutorius/bundler.py index 734c679..c9558b1 100644 --- a/tutorius/bundler.py +++ b/tutorius/bundler.py @@ -24,6 +24,7 @@ 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 @@ -37,8 +38,10 @@ def _get_store_root(): 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 os.path.join(os.getenv("SUGAR_BUNDLE_PATH"),"data","tutorius","data") +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" @@ -48,46 +51,9 @@ INI_XML_FSM_PROPERTY = "FSM_FILENAME" INI_FILENAME = "meta.ini" TUTORIAL_FILENAME = "tutorial.xml" NODE_COMPONENT = "Component" -NODE_SUBCOMPONENT = "SubComponent" -NODE_SUBCOMPONENTLIST = "SubComponentList" - -class Vault(object): - """ - The Vault is the primary interface for the storage and installation of tutorials - on the machine. It needs to accomplish the following tasks : - - query() : Lists the - - installTutorial() : - - deleteTutorial() : - - readTutorial() : - - saveTutorial() : - """ - def query(keyword="", category="", start_index=0, num_results=10): - """ - Returns a list of tutorial meta-data corresponding to the keywords - and category mentionned. - - @param keyword The keyword to look for in the tutorial title and description. - @param category The category in which to look for tutorials - @param start_index The first result to be shown (e.g. ) - @param num_results The number of results to show - @return The list of tutorial metadata that corresponds to the query parameters. - """ - raise NotImplementedError("The query function on the Vault is not implemented") - - def installTutorial(path ,force_install=False): - """ - Inserts the tutorial inside the Vault. Once installed, it will show up - """ - raise NotImplementedError("Installation in the Vault not supported yet") - - def deleteTutorial(tutorial_id): - raise NotImplementedError("") - - def readTutorial(tutorial_id): - raise NotImplementedError("") - - def saveTutorial(tutorial, metadata, resource_list): - raise NotImplementedError("") +NODE_SUBCOMPONENT = "property" +NODE_SUBCOMPONENTLIST = "listproperty" +NEXT_STATE_ATTR = "next_state" class TutorialStore(object): @@ -99,7 +65,7 @@ class TutorialStore(object): @returns a map of tutorial {names : GUID}. """ # check both under the activity data and user installed folders - paths = [_get_store_root(), _get_bundle_root()] + paths = [p for p in [_get_store_root(), _get_bundle_root()] if p ] tutoGuidName = {} @@ -127,7 +93,7 @@ class TutorialStore(object): return tutoGuidName - def load_tutorial(self, Guid): + def load_tutorial(self, Guid, bundle_path=None): """ Rebuilds a tutorial object from it's serialized state. Common storing paths will be scanned. @@ -135,15 +101,15 @@ class TutorialStore(object): @param Guid the generic identifier of the tutorial @returns a Tutorial object containing an FSM """ - bundle = TutorialBundler(Guid) - bundle_path = bundle.get_tutorial_path() + bundler = TutorialBundler(Guid, bundle_path=bundle_path) + bundler_path = bundler.get_tutorial_path() config = SafeConfigParser() - config.read(os.path.join(bundle_path, INI_FILENAME)) + 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) + fsm = serializer.load_fsm(Guid, bundler.Path) tuto = Tutorial(name, fsm) return tuto @@ -163,13 +129,13 @@ class Serializer(object): exception occur. If no GUID is provided, FSM is written in a new file in the store root. """ - NotImplementedError + return NotImplementedError() def load_fsm(self): """ Load fsm from disk. """ - NotImplementedError + return NotImplementedError() class XMLSerializer(Serializer): """ @@ -197,9 +163,9 @@ class XMLSerializer(Serializer): e.g. - + - + When reloading this node, we should look up the property name for the parent @@ -215,7 +181,7 @@ class XMLSerializer(Serializer): that represents another component. """ subCompNode = doc.createElement(NODE_SUBCOMPONENT) - subCompNode.setAttribute("property", parent_attr_name) + subCompNode.setAttribute("name", parent_attr_name) subNode = self._create_component_node(comp, doc) @@ -231,10 +197,10 @@ class XMLSerializer(Serializer): e.g. - + - + When reloading this node, we should look up the property name for the parent @@ -247,7 +213,7 @@ class XMLSerializer(Serializer): @returns A NODE_SUBCOMPONENTLIST node with the property attribute """ subCompListNode = doc.createElement(NODE_SUBCOMPONENTLIST) - subCompListNode.setAttribute("property", parent_attr_name) + subCompListNode.setAttribute("name", parent_attr_name) for comp in comp_list: compNode = self._create_component_node(comp, doc) @@ -308,8 +274,9 @@ class XMLSerializer(Serializer): Create and return a xml Node from a event filters. """ eventFiltersList = doc.createElement("EventFiltersList") - for event_f in event_filters: - eventFilterNode = self._create_component_node(event_f, doc) + 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 @@ -423,24 +390,29 @@ class XMLSerializer(Serializer): @param filters_elem An XML Element representing a list of event filters """ - reformed_event_filters_list = [] + transition_list = [] event_filter_element_list = self._get_direct_descendants_by_tag_name(filters_elem, 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: - reformed_event_filters_list.append(new_event_filter) + transition_list.append((new_event_filter, next_state)) - return reformed_event_filters_list + return transition_list def _load_xml_subcomponents(self, 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 SubComponent nodes. + @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. @@ -448,7 +420,7 @@ class XMLSerializer(Serializer): subCompList = self._get_direct_descendants_by_tag_name(node, NODE_SUBCOMPONENT) for subComp in subCompList: - property_name = subComp.getAttribute("property") + property_name = subComp.getAttribute("name") internal_comp_node = self._get_direct_descendants_by_tag_name(subComp, NODE_COMPONENT)[0] internal_comp = self._load_xml_component(internal_comp_node) properties[str(property_name)] = internal_comp @@ -464,7 +436,7 @@ class XMLSerializer(Serializer): """ listOf_subCompListNode = self._get_direct_descendants_by_tag_name(node, NODE_SUBCOMPONENTLIST) for subCompListNode in listOf_subCompListNode: - property_name = subCompListNode.getAttribute("property") + property_name = subCompListNode.getAttribute("name") subCompList = [] for subCompNode in self._get_direct_descendants_by_tag_name(subCompListNode, NODE_COMPONENT): subComp = self._load_xml_component(subCompNode) @@ -568,18 +540,18 @@ class XMLSerializer(Serializer): # Load the event filters events = self._load_xml_event_filters(fsm_elem.getElementsByTagName("EventFiltersList")[0]) - for event in events: - fsm.add_event_filter(event) + for event, next_state in events: + fsm.add_event_filter(event, next_state) return fsm - def load_fsm(self, guid): + 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 = self._find_tutorial_dir_with_guid(guid) + tutorial_dir = path or self._find_tutorial_dir_with_guid(guid) # Open the XML file tutorial_file = os.path.join(tutorial_dir, TUTORIAL_FILENAME) @@ -597,7 +569,7 @@ class TutorialBundler(object): editor. """ - def __init__(self,generated_guid = None): + 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, @@ -606,6 +578,7 @@ class TutorialBundler(object): 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 @@ -614,9 +587,13 @@ class TutorialBundler(object): self.Path = os.path.dirname(store_path) else: #Bundle store - bundle_path = os.path.join(_get_bundle_root(), generated_guid, INI_FILENAME) - if os.path.isfile(bundle_path): - self.Path = os.path.dirname(bundle_path) + 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) diff --git a/tutorius/constraints.py b/tutorius/constraints.py index 36abdfb..e91f23a 100644 --- a/tutorius/constraints.py +++ b/tutorius/constraints.py @@ -200,7 +200,10 @@ class FileConstraint(Constraint): def validate(self, value): # TODO : Decide on the architecture for file retrieval on disk # Relative paths? From where? Support macros? - # + # FIXME This is a hack to make cases where a default file is not valid + # work. It allows None values to be validated, though + if value is None: + return if not os.path.isfile(value): raise FileConstraintError("Non-existing file : %s"%value) return diff --git a/tutorius/core.py b/tutorius/core.py index 41089f1..6030457 100644 --- a/tutorius/core.py +++ b/tutorius/core.py @@ -21,14 +21,12 @@ This module contains the core classes for tutorius """ -import gtk import logging -import copy import os -from sugar.tutorius.dialog import TutoriusDialog -from sugar.tutorius.gtkutils import find_widget -from sugar.tutorius.services import ObjectStore +from sugar.tutorius.TProbe import ProbeManager +from sugar.tutorius.dbustools import save_args +from sugar.tutorius import addon logger = logging.getLogger("tutorius") @@ -36,8 +34,11 @@ class Tutorial (object): """ Tutorial Class, used to run through the FSM. """ + #Properties + probeManager = property(lambda self: self._probeMgr) + activityId = property(lambda self: self._activity_id) - def __init__(self, name, fsm,filename= None): + def __init__(self, name, fsm, filename=None): """ Creates an unattached tutorial. """ @@ -51,21 +52,22 @@ class Tutorial (object): self.state = None self.handlers = [] - self.activity = None + self._probeMgr = ProbeManager() + self._activity_id = None #Rest of initialisation happens when attached - def attach(self, activity): + def attach(self, activity_id): """ Attach to a running activity - @param activity the activity to attach to + @param activity_id the id of the activity to attach to """ #For now, absolutely detach if a previous one! - if self.activity: + if self._activity_id: self.detach() - self.activity = activity - ObjectStore().activity = activity - ObjectStore().tutorial = self + self._activity_id = activity_id + self._probeMgr.attach(activity_id) + self._probeMgr.currentActivity = activity_id self._prepare_activity() self.state_machine.set_state("INIT") @@ -77,9 +79,9 @@ class Tutorial (object): # Uninstall the whole FSM self.state_machine.teardown() - #FIXME There should be some amount of resetting done here... - self.activity = None - + if not self._activity_id is None: + self._probeMgr.detach(self._activity_id) + self._activity_id = None def set_state(self, name): """ @@ -89,18 +91,6 @@ class Tutorial (object): self.state_machine.set_state(name) - - # Currently unused -- equivalent function is in each state - def _eventfilter_state_done(self, eventfilter): - """ - Callback handler for eventfilter to notify - when we must go to the next state. - """ - #XXX Tests should be run here normally - - #Swith to the next state pointed by the eventfilter - self.set_state(eventfilter.get_next_state()) - def _prepare_activity(self): """ Prepare the activity for the tutorial by loading the saved state and @@ -112,9 +102,11 @@ class Tutorial (object): #of the activity root directory filename = os.getenv("SUGAR_ACTIVITY_ROOT") + "/data/" +\ self.activity_init_state_filename - if os.path.exists(filename): - self.activity.read_file(filename) - + readfile = addon.create("ReadFile", filename=filename) + if readfile: + self._probeMgr.install(readfile) + #Uninstall now while we have the reference handy + self._probeMgr.uninstall(readfile) class State(object): """ @@ -141,10 +133,9 @@ class State(object): self._actions = action_list or [] - # Unused for now - #self.tests = [] + self._transitions= dict(event_filter_list or []) - self._event_filters = event_filter_list or [] + self._installedEvents = set() self.tutorial = tutorial @@ -168,12 +159,11 @@ class State(object): Install the state itself, by first registering the event filters and then triggering the actions. """ - for eventfilter in self._event_filters: - eventfilter.install_handlers(self._event_filter_state_done_cb, - activity=self.tutorial.activity) + for (event, next_state) in self._transitions.items(): + self._installedEvents.add(self.tutorial.probeManager.subscribe(event, save_args(self._event_filter_state_done_cb, next_state ))) for action in self._actions: - action.do() + self.tutorial.probeManager.install(action) def teardown(self): """ @@ -182,38 +172,37 @@ class State(object): removing dialogs that were displayed, removing highlights, etc... """ # Remove the handlers for the all of the state's event filters - for event_filter in self._event_filters: - event_filter.remove_handlers() + while len(self._installedEvents) > 0: + self.tutorial.probeManager.unsubscribe(self._installedEvents.pop()) # Undo all the actions related to this state for action in self._actions: - action.undo() + self.tutorial.probeManager.uninstall(action) - def _event_filter_state_done_cb(self, event_filter): + def _event_filter_state_done_cb(self, next_state, event): """ Callback for event filters. This function needs to inform the tutorial that the state is over and tell it what is the next state. - @param event_filter The event filter that was called + @param next_state The next state for the transition + @param event The event that occured """ # Run the tests here, if need be # Warn the higher level that we wish to change state - self.tutorial.set_state(event_filter.get_next_state()) + self.tutorial.set_state(next_state) # Model manipulation # 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 @@ -229,19 +218,21 @@ class State(object): Removes all the action associated with this state. A cleared state will not do anything when entered or exited. """ + #FIXME What if the action is currently installed? self._actions = [] - def add_event_filter(self, event_filter): + def add_event_filter(self, event, next_state): """ Adds an event filter that will cause a transition from this state. The same event filter may not be added twice. - @param event_filter The new event filter that will trigger a transition + @param event The event that will trigger a transition + @param next_state The state to which the transition will lead @return True if added, False otherwise """ - if event_filter not in self._event_filters: - self._event_filters.append(event_filter) + if event not in self._transitions.keys(): + self._transitions[event]=next_state return True return False @@ -249,7 +240,7 @@ class State(object): """ @return The list of event filters associated with this state. """ - return self._event_filters + return self._transitions.items() def clear_event_filters(self): """ @@ -257,12 +248,19 @@ class State(object): was just cleared will become a sink and will be the end of the tutorial. """ - self._event_filters = [] + self._transitions = {} - def is_identical(self, otherState): + def __eq__(self, otherState): """ - Compares two states and tells whether they contain the same states and + 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 @@ -272,27 +270,38 @@ class State(object): # Do they have the same actions? if len(self._actions) != len(otherState._actions): return False + + if len(self._event_filters) != len(otherState._event_filters): + 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.is_identical(otherAct): + 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 len(self._actions) != len(otherState._actions): - return False for event in self._event_filters: found = False + # For every event filter in the other state, try to match it with + # the current filter. We just need to find one with the right + # properties and values. for otherEvent in otherState._event_filters: - if event.is_identical(otherEvent): + if event == otherEvent: found = True break - if found == False: + if found == False: + # We could not find the given event filter in the other state. 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): @@ -385,7 +394,7 @@ class FiniteStateMachine(State): self._fsm_setup_done = True # Execute all the FSM level actions for action in self.actions: - action.do() + self.tutorial.probeManager.install(action) # Then, we need to run the setup of the current state self.current_state.setup() @@ -450,7 +459,7 @@ class FiniteStateMachine(State): self._fsm_teardown_done = True # Undo all the FSM level actions here for action in self.actions: - action.undo() + self.tutorial.probeManager.uninstall(action) # TODO : It might be nice to have a start() and stop() method for the # FSM. @@ -506,9 +515,9 @@ class FiniteStateMachine(State): #TODO : Move this code inside the State itself - we're breaking # encap :P - for event_filter in st._event_filters: - if event_filter.get_next_state() == state_name: - st._event_filters.remove(event_filter) + for event, state in st._transitions: + if state == state_name: + del st._transitions[event] # Remove the state from the dictionary del self._states[state_name] @@ -526,8 +535,8 @@ class FiniteStateMachine(State): next_states = set() - for event_filter in state._event_filters: - next_states.add(event_filter.get_next_state()) + for event, state in state._transitions: + next_states.add(state) return tuple(next_states) @@ -549,9 +558,9 @@ class FiniteStateMachine(State): states = [] # Walk through the list of states for st in self._states.itervalues(): - for event_filter in st._event_filters: - if event_filter.get_next_state() == state_name: - states.append(event_filter.get_next_state()) + for event, state in st._transitions: + if state == state_name: + states.append(state) continue return tuple(states) @@ -563,42 +572,57 @@ class FiniteStateMachine(State): out_string += st.name + ", " return out_string - def is_identical(self, otherFSM): + 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 + @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.is_identical(otherAct): + 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 state in self._states.itervalues(): - found = False - for otherState in otherFSM._states.itervalues(): - if state.is_identical(otherState): - found = True - break - if found == 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 513e312..efa17c3 100644 --- a/tutorius/creator.py +++ b/tutorius/creator.py @@ -114,7 +114,6 @@ class Creator(object): """ self.introspecting = False eventfilter = addon.create('GtkWidgetEventFilter', - next_state=None, object_id=self._selected_widget, event_name=event_name) # undo actions so they don't persist through step editing @@ -207,9 +206,13 @@ class Creator(object): had_introspect = True self.introspecting = True elif isinstance(prop, properties.TStringProperty): - dlg = TextInputDialog(title="Mandatory property", + 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() @@ -240,7 +243,7 @@ class Creator(object): Quit editing and cleanup interface artifacts. """ self.introspecting = False - eventfilter = filters.EventFilter(None) + eventfilter = filters.EventFilter() # undo actions so they don't persist through step editing for action in self._tutorial.current_actions: action.exit_editmode() @@ -396,7 +399,10 @@ class EditToolBox(gtk.Window): def _list_prop_changed(self, widget, evt, action, propname, idx): try: - getattr(action, propname)[idx] = int(widget.get_text()) + #Save props as tuples so that they can be hashed + attr = list(getattr(action, propname)) + attr[idx] = int(widget.get_text()) + setattr(action, propname, tuple(attr)) except ValueError: widget.set_text(str(getattr(action, propname)[idx])) self.__parent._creator._action_refresh_cb(None, None, action) diff --git a/tutorius/dbustools.py b/tutorius/dbustools.py new file mode 100644 index 0000000..1b685d7 --- /dev/null +++ b/tutorius/dbustools.py @@ -0,0 +1,41 @@ +import logging +LOGGER = logging.getLogger("sugar.tutorius.dbustools") + +def save_args(callable, *xargs, **xkwargs): + def __call(*args, **kwargs): + kw = dict() + kw.update(kwargs) + kw.update(xkwargs) + return callable(*(xargs+args), **kw) + return __call + +def ignore(*args): + LOGGER.debug("Unhandled asynchronous dbus call response with arguments: %s", str(args)) + +def logError(error): + LOGGER.error("Unhandled asynchronous dbus call error: %s", error) + +def remote_call(callable, args, return_cb=None, error_cb=None, block=False): + reply_cb = return_cb or ignore + errhandler_cb = error_cb or logError + if block: + try: + ret_val = callable(*args) + LOGGER.debug("remote_call return arguments: %s", str(ret_val)) + except Exception, e: + #Use the specified error handler even for blocking calls + errhandler_cb(e) + + #Return value signature might be : + if ret_val is None: + #Nothing + return reply_cb() + elif type(ret_val) in (list, tuple): + #Several parameters + return reply_cb(*ret_val) + else: + #One parameter + return reply_cb(ret_val) + else: + callable(*args, reply_handler=reply_cb, error_handler=errhandler_cb) + diff --git a/tutorius/engine.py b/tutorius/engine.py new file mode 100644 index 0000000..dda9f3f --- /dev/null +++ b/tutorius/engine.py @@ -0,0 +1,48 @@ +import logging +import dbus.mainloop.glib +from jarabe.model import shell + +from sugar.tutorius.bundler import TutorialStore +from sugar.bundle.activitybundle import ActivityBundle + +class Engine: + """ + Driver for the execution of tutorials + """ + + def __init__(self): + # FIXME Probe management should be in the probe manager + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + #FIXME shell.get_model() will only be useful in the shell process + self._shell = shell.get_model() + self._tutorial = None + + def launch(self, tutorialID): + """ Launch a tutorial + @param tutorialID unique tutorial identifier used to retrieve it from the disk + """ + if self._tutorial: + 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()) + + #TProbes automatically use the bundle id, available from the ActivityBundle + bundle = ActivityBundle(activity.get_bundle_path()) + self._tutorial.attach(bundle.get_bundle_id()) + + def stop(self): + """ Stop the current tutorial + """ + self._tutorial.detach() + self._tutorial = None + + def pause(self): + """ Interrupt the current tutorial and save its state in the journal + """ + raise NotImplementedError("Unable to store tutorial state") + diff --git a/tutorius/filters.py b/tutorius/filters.py index fc58562..44621d5 100644 --- a/tutorius/filters.py +++ b/tutorius/filters.py @@ -15,13 +15,9 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -import gobject -import gtk import logging logger = logging.getLogger("filters") -from sugar.tutorius.gtkutils import find_widget -from sugar.tutorius.services import ObjectStore from sugar.tutorius import properties @@ -30,31 +26,13 @@ class EventFilter(properties.TPropContainer): Base class for an event filter """ - next_state = properties.TStringProperty("None") - - def __init__(self, next_state=None): + def __init__(self): """ Constructor. - @param next_state name of the next state """ super(EventFilter, self).__init__() - if next_state: - self.next_state = next_state self._callback = None - def get_next_state(self): - """ - Getter for the next state - """ - return self.next_state - - def set_next_state(self, new_next_name): - """ - Setter for the next state. Should only be used during construction of - the event_fitler, not while the tutorial is running. - """ - self.next_state = new_next_name - def install_handlers(self, callback, **kwargs): """ install_handlers is called for eventfilters to setup all @@ -94,111 +72,3 @@ class EventFilter(properties.TPropContainer): if self._callback: self._callback(self) -##class TimerEvent(EventFilter): -## """ -## TimerEvent is a special EventFilter that uses gobject -## timeouts to trigger a state change after a specified amount -## of time. It must be used inside a gobject main loop to work. -## """ -## def __init__(self,next_state,timeout_s): -## """Constructor. -## -## @param next_state default EventFilter param, passed on to EventFilter -## @param timeout_s timeout in seconds -## """ -## super(TimerEvent,self).__init__(next_state) -## self._timeout = timeout_s -## self._handler_id = None -## -## def install_handlers(self, callback, **kwargs): -## """install_handlers creates the timer and starts it""" -## super(TimerEvent,self).install_handlers(callback, **kwargs) -## #Create the timer -## self._handler_id = gobject.timeout_add_seconds(self._timeout, self._timeout_cb) -## -## def remove_handlers(self): -## """remove handler removes the timer""" -## super(TimerEvent,self).remove_handlers() -## if self._handler_id: -## try: -## #XXX What happens if this was already triggered? -## #remove the timer -## gobject.source_remove(self._handler_id) -## except: -## pass -## -## def _timeout_cb(self): -## """ -## _timeout_cb triggers the eventfilter callback. -## -## It is necessary because gobject timers only stop if the callback they -## trigger returns False -## """ -## self.do_callback() -## return False #Stops timeout -## -##class GtkWidgetTypeFilter(EventFilter): -## """ -## Event Filter that listens for keystrokes on a widget -## """ -## def __init__(self, next_state, 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__(next_state) -## 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() - diff --git a/tutorius/linear_creator.py b/tutorius/linear_creator.py index 91b11f4..78e94ce 100644 --- a/tutorius/linear_creator.py +++ b/tutorius/linear_creator.py @@ -58,9 +58,8 @@ class LinearCreator(object): # Set the next state name - there is no way the caller should have # to deal with that. next_state_name = "State %d" % (self.nb_state+1) - event_filter.set_next_state(next_state_name) state = State(self.state_name, action_list=self.current_actions, - event_filter_list=[event_filter]) + event_filter_list=[(event_filter, next_state_name),]) self.state_name = next_state_name self.nb_state += 1 diff --git a/tutorius/overlayer.py b/tutorius/overlayer.py index 6b1b948..0a3d542 100644 --- a/tutorius/overlayer.py +++ b/tutorius/overlayer.py @@ -157,7 +157,7 @@ class TextBubble(gtk.Widget): A CanvasDrawableWidget drawing a round textbox and a tail pointing to a specified widget. """ - def __init__(self, text, speaker=None, tailpos=[0,0]): + def __init__(self, text, speaker=None, tailpos=(0,0)): """ Creates a new cairo rendered text bubble. @@ -199,7 +199,7 @@ class TextBubble(gtk.Widget): # TODO fetch speaker coordinates # draw bubble tail if present - if self.tailpos != [0,0]: + if self.tailpos != (0,0): context.move_to(xradius-width/4, yradius) context.line_to(self.tailpos[0], self.tailpos[1]) context.line_to(xradius+width/4, yradius) @@ -228,7 +228,7 @@ class TextBubble(gtk.Widget): context.fill() # bubble painting. Redrawing the inside after the tail will combine - if self.tailpos != [0,0]: + if self.tailpos != (0,0): context.move_to(xradius-width/4, yradius) context.line_to(self.tailpos[0], self.tailpos[1]) context.line_to(xradius+width/4, yradius) diff --git a/tutorius/properties.py b/tutorius/properties.py index 6d30a8d..78e3c2b 100644 --- a/tutorius/properties.py +++ b/tutorius/properties.py @@ -95,39 +95,25 @@ class TPropContainer(object): """ return object.__getattribute__(self, "_props").keys() - def is_identical(self, otherContainer): - for prop in self._props.keys(): - found = False - for otherProp in otherContainer._props.keys(): - if prop == otherProp: - this_type = getattr(type(self), prop).type - other_type = getattr(type(otherContainer), prop).type - if this_type != other_type: - return False - if this_type == "addonlist": - for inner_cont in self._props[prop]: - inner_found = False - for other_inner in otherContainer._props[prop]: - if inner_cont.is_identical(other_inner): - inner_found = True - break - if inner_found == False: - return False - found = True - break - elif this_type == "addon": - if not self._props[prop].is_identical(otherContainer._props[prop]): - return False - found = True - break - else: - if self._props[prop]== otherContainer._props[prop]: - found = True - break - if found == False: - return False - return True - + # Providing the hash methods necessary to use TPropContainers + # in a dictionary, according to their properties + def __hash__(self): + #Return a hash of properties (key, value) sorted by key + #We need to transform the list of property key, value lists into + # a tuple of key, value tuples + 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 == e2._props + + # 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) + class TutoriusProperty(object): """ The base class for all actions' properties. The interface is the following : @@ -178,19 +164,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 @@ -240,8 +213,20 @@ 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): + self.max_size_limit = MaxSizeConstraint(state["max_size_limit"]) + self.min_size_limit = MinSizeConstraint(state["min_size_limit"]) + self.value = state["value"] + + def __getstate__(self): + return dict( + max_size_limit=self.max_size_limit.limit, + min_size_limit=self.min_size_limit.limit, + value=self.value, + ) class TColorProperty(TutoriusProperty): """ Represents a RGB color with 3 8-bit integer values. @@ -320,8 +305,10 @@ class TUAMProperty(TutoriusProperty): """ Represents a widget of the interface by storing its UAM. """ - # TODO : Pending UAM check-in (LP 355199) - pass + def __init__(self, value=None): + TutoriusProperty.__init__(self) + + self.type = "uam" class TAddonProperty(TutoriusProperty): """ diff --git a/tutorius/service.py b/tutorius/service.py new file mode 100644 index 0000000..21f0cf1 --- /dev/null +++ b/tutorius/service.py @@ -0,0 +1,85 @@ +from engine import Engine +import dbus + +from dbustools import remote_call + +_DBUS_SERVICE = "org.tutorius.Service" +_DBUS_PATH = "/org/tutorius/Service" +_DBUS_SERVICE_IFACE = "org.tutorius.Service" + +class Service(dbus.service.Object): + """ + Global tutorius entry point to control the whole system + """ + + def __init__(self): + bus = dbus.SessionBus() + bus_name = dbus.service.BusName(_DBUS_SERVICE, bus=bus) + dbus.service.Object.__init__(self, bus_name, _DBUS_PATH) + + self._engine = None + + def start(self): + """ Start the service itself + """ + # For the moment there is nothing to do + pass + + + @dbus.service.method(_DBUS_SERVICE_IFACE, + in_signature="s", out_signature="") + def launch(self, tutorialID): + """ Launch a tutorial + @param tutorialID unique tutorial identifier used to retrieve it from the disk + """ + if self._engine == None: + self._engine = Engine() + self._engine.launch(tutorialID) + + @dbus.service.method(_DBUS_SERVICE_IFACE, + in_signature="", out_signature="") + def stop(self): + """ Stop the current tutorial + """ + self._engine.stop() + + @dbus.service.method(_DBUS_SERVICE_IFACE, + in_signature="", out_signature="") + def pause(self): + """ Interrupt the current tutorial and save its state in the journal + """ + self._engine.pause() + +class ServiceProxy: + """ Proxy to connect to the Service object, abstracting the DBus interface""" + + def __init__(self): + bus = dbus.SessionBus() + self._object = bus.get_object(_DBUS_SERVICE,_DBUS_PATH) + self._service = dbus.Interface(self._object, _DBUS_SERVICE_IFACE) + + def launch(self, tutorialID): + """ Launch a tutorial + @param tutorialID unique tutorial identifier used to retrieve it from the disk + """ + remote_call(self._service.launch, (tutorialID, ), block=False) + + def stop(self): + """ Stop the current tutorial + """ + remote_call(self._service.stop, (), block=False) + + def pause(self): + """ Interrupt the current tutorial and save its state in the journal + """ + remote_call(self._service.pause, (), block=False) + +if __name__ == "__main__": + import dbus.mainloop.glib + import gobject + + loop = gobject.MainLoop() + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + s = Service() + loop.run() + diff --git a/tutorius/services.py b/tutorius/services.py index 9ed2e50..e7b17d8 100644 --- a/tutorius/services.py +++ b/tutorius/services.py @@ -22,6 +22,9 @@ This module supplies services to be used by States, FSMs, Actions and Filters. Services provided are: -Access to the running activity -Access to the running tutorial + +TODO: Passing the activity reference should be done by the Probe instead +of being a global variable. """ diff --git a/tutorius/store.py b/tutorius/store.py new file mode 100644 index 0000000..480c81b --- /dev/null +++ b/tutorius/store.py @@ -0,0 +1,173 @@ +# 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 + +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 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. + """ + raise NotImplementedError("get_categories() not implemented") + + def get_tutorials(self, keywords=None, category=None, startIndex=0, numResults=10, sortBy='name'): + """ + Returns the list of tutorials that correspond to the given search criteria. + + @param keywords The list of keywords that should be matched inside the tutorial title + or description. If None, the search will not filter the results + according to the keywords. + @param category The category in which to restrict the search. + @param startIndex The index 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 + @param sortBy The field on which to sort the results + @return A list of tutorial meta-data that corresponds to the query + """ + raise NotImplementedError("get_tutorials() not implemented") + + def get_tutorial_collection(self, collection_name): + """ + Returns a list of tutorials corresponding to the given collection name. + Collections can be groups like '5 most downloaded' or 'Top 10 ratings'. + + @param collection_name The name of the collection from which we want the + meta-data + @return A list of tutorial meta-data corresponding to the given group + """ + raise NotImplementedError("get_tutorial_collection() not implemented... yet!") + + 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. + """ + raise NotImplementedError("get_latest_version() not implemented") + + 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. + """ + raise NotImplementedError("downloadTutorial() not implemented") + + 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. + + @return True if the login was successful, False otherwise + """ + raise NotImplementedError("login() not implemented yet") + + 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 + """ + raise NotImplementedError("close_session() not implemented yet") + + 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 + """ + raise NotImplementedError("get_session_id() not implemented yet") + + 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. + """ + raise NotImplementedError("rate() not implemented") + + def publish(self, tutorial): + """ + 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. + @return True if the tutorial was sent correctly, False otherwise. + """ + raise NotImplemetedError("publish() not implemented") + + 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 + """ + raise NotImplementedError("unpublish() not implemeted") + + def update_published_tutorial(self, tutorial_id, tutorial): + """ + 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 + """ + raise NotImplementedError("update_published_tutorial() not implemented yet") + + 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 + """ + raise NotImplementedError("register_new_user() not implemented") -- cgit v0.9.1