From f14bcb9b6c79f7f071e32a7ef9bba5ce440096bd Mon Sep 17 00:00:00 2001 From: Vincent Vinet Date: Thu, 15 Oct 2009 14:23:57 +0000 Subject: Big commit! Everything to make Tutorial execution work through dbus. --- diff --git a/addons/gtkwidgeteventfilter.py b/addons/gtkwidgeteventfilter.py index cbfb00c..f5bd0de 100644 --- a/addons/gtkwidgeteventfilter.py +++ b/addons/gtkwidgeteventfilter.py @@ -15,6 +15,7 @@ # 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.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/timerevent.py b/addons/timerevent.py new file mode 100644 index 0000000..7b4292c --- /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_s 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" : "Timer", + "icon" : "player_play", + "class" : TimerEvent, + "mandatory_props" : ["timeout"] +} + 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/run-tests.py b/tests/run-tests.py index d41aa0a..23d7e24 100755 --- a/tests/run-tests.py +++ b/tests/run-tests.py @@ -2,9 +2,9 @@ # 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 +INSTALL_PATH=os.path.join(os.path.dirname(__file__),"../../sugar-jhbuild/install/lib/python2.6/site-packages/") sys.path.insert(0, os.path.abspath(INSTALL_PATH) ) @@ -40,6 +40,7 @@ if __name__=='__main__': import constraintstests import propertiestests import serializertests + import probetests suite = unittest.TestSuite() suite.addTests(unittest.findTestCases(coretests)) suite.addTests(unittest.findTestCases(servicestests)) @@ -52,6 +53,7 @@ if __name__=='__main__': suite.addTests(unittest.findTestCases(constraintstests)) suite.addTests(unittest.findTestCases(propertiestests)) suite.addTests(unittest.findTestCases(serializertests)) + suite.addTests(unittest.findTestCases(probetests)) runner = unittest.TextTestRunner() runner.run(suite) coverage.stop() @@ -70,5 +72,6 @@ if __name__=='__main__': from propertiestests import * from actiontests import * from serializertests import * + from probetests import * unittest.main() diff --git a/tutorius/TProbe.py b/tutorius/TProbe.py new file mode 100644 index 0000000..6c0883a --- /dev/null +++ b/tutorius/TProbe.py @@ -0,0 +1,496 @@ +import logging +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 | + -------------------- ---------- + +""" + +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. + + Exposes the following dbus methods: + void registered(string service) + string ping() -> status + string install(string action) -> address + void update(string address, string action_props) + void uninstall(string address) + string subscribe(string pickled_event) -> address + void unsubscribe(string address) + + Exposes the following dbus Events: + eventOccured(event): + + """ + + 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 + """ + logging.debug("TProbe :: Creating TProbe for %s (%d)", activity_name, os.getpid()) + logging.debug("TProbe :: Current gobject context: %s", str(gobject.main_context_default())) + logging.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 + """ + 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): + logging.debug("TProbe :: notify event %s", str(event)) + #HACK: reinstanciate the event with it's properties, to clear + # any internal state from getting pickled + if isinstance(event, TPropContainer): + newevent = type(event)(**event._props) + else: + newevent = event + self.eventOccured(pickle.dumps(newevent)) + + # 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__ + suffix = 1 + + while self._subscribedEvents.has_key(name+str(suffix)): + suffix += 1 + + 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. + + Public Methods: + ProbeProxy(string activityName) :: Constructor + string install(Action action) + void update(Action action) + void uninstall(Action action) + void uninstall_all() + string subscribe(Event event, callable callback) + void unsubscribe(Event event, callable callback) + void unsubscribe_all() + """ + def __init__(self, activityName): + """ + Constructor + @param activityName unique activity id + """ + logging.debug("ProbeProxy :: Creating ProbeProxy for %s (%d)", activityName, os.getpid()) + logging.debug("ProbeProxy :: Current gobject context: %s", str(gobject.main_context_default())) + logging.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)) + logging.debug("ProbeProxy :: Received Event : %s %s", str(event), str(event._props.items())) + + logging.debug("ProbeProxy :: Currently %d events registered", len(self._registeredCallbacks)) + if self._registeredCallbacks.has_key(event): + for callback in self._registeredCallbacks[event].itervalues(): + callback(event) + else: + for event in self._registeredCallbacks.keys(): + logging.debug("==== %s", str(event._props.items())) + logging.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): + 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 + @return None + """ + remote_call(self._probe.install, (pickle.dumps(action),), + save_args(self.__update_action, action), + block=block) + + def update(self, action, block=False): + """ + Update an already installed action's properties and run it again + @param action Action to update + @return None + """ + if not action in self._actions: + raise RuntimeWarning("Action not installed") + return + remote_call(self._probe.update, (self._actions[action], pickle.dumps(action._props)), block=block) + + def uninstall(self, action, block=False): + """ + Uninstall an installed action + @param action Action to uninstall + """ + if action in self._actions: + remote_call(self._probe.uninstall,(self._actions.pop(action),), block=block) + + def uninstall_all(self, block=False): + """ + Uninstall all installed actions + @return None + """ + for action in self._actions.keys(): + self.uninstall(action, block) + + def __update_event(self, event, callback, address): + logging.debug("ProbeProxy :: Registered event %s with address %s", str(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): + # 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) + + 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 + @return address identifier used for unsubscribing + """ + 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=False): + """ + Unregister an event listener + @param address identifier given by subscribe() + @return None + """ + if address in self._subscribedEvents.keys(): + remote_call(self._probe.unsubscribe, (address,), + return_cb=save_args(self.__clear_event, address), + block=block) + else: + logging.debug("ProbeProxy :: unsubsribe address %s failed : not registered", address) + + def unsubscribe_all(self, block=False): + """ + Unregister all event listeners + @return None + """ + 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. + """ + 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") + return + + self._probes[activity_id] = ProbeProxy(activity_id) + 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.unsubscribe_all() + probe.uninstall_all() + + def install(self, action): + if self.currentActivity: + return self._probes[self.currentActivity].install(action) + else: + raise RuntimeWarning("No activity attached") + + def update(self, action): + if self.currentActivity: + return self._probes[self.currentActivity].update(action) + else: + raise RuntimeWarning("No activity attached") + + def uninstall(self, action): + if self.currentActivity: + return self._probes[self.currentActivity].uninstall(action) + else: + raise RuntimeWarning("No activity attached") + + def uninstall_all(self): + if self.currentActivity: + return self._probes[self.currentActivity].uninstall_all() + else: + raise RuntimeWarning("No activity attached") + + def subscribe(self, event, callback): + if self.currentActivity: + return self._probes[self.currentActivity].subscribe(event, callback) + else: + raise RuntimeWarning("No activity attached") + + def unsubscribe(self, address): + if self.currentActivity: + return self._probes[self.currentActivity].unsubscribe(address) + else: + raise RuntimeWarning("No activity attached") + + def unsubscribe_all(self): + if self.currentActivity: + return self._probes[self.currentActivity].unsubscribe_all() + else: + raise RuntimeWarning("No activity attached") + diff --git a/tutorius/core.py b/tutorius/core.py index dd2435e..f51c5fb 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") @@ -37,7 +35,7 @@ class Tutorial (object): Tutorial Class, used to run through the FSM. """ - def __init__(self, name, fsm,filename= None): + def __init__(self, name, fsm, filename=None): """ Creates an unattached tutorial. """ @@ -51,21 +49,25 @@ 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): + probeManager = property(lambda self: self._probeMgr) + activityId = property(lambda self: self._activity_id) + + 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,10 @@ class Tutorial (object): # Uninstall the whole FSM self.state_machine.teardown() - #FIXME There should be some amount of resetting done here... - self.activity = None - + #FIXME (Old) There should be some amount of resetting done here... + if not self._activity_id is None: + self._probeMgr.detach(self._activity_id) + self._activity_id = None def set_state(self, name): """ @@ -90,17 +93,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 +104,10 @@ 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) + self._probeMgr.uninstall(readfile) class State(object): """ @@ -144,7 +137,9 @@ class State(object): # Unused for now #self.tests = [] - self._event_filters = event_filter_list or [] + self._transitions= dict(event_filter_list or []) + + self._installedEvents = set() self.tutorial = tutorial @@ -168,12 +163,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,24 +176,25 @@ 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 @@ -229,19 +224,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 +246,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,7 +254,7 @@ class State(object): was just cleared will become a sink and will be the end of the tutorial. """ - self._event_filters = [] + self._transitions = {} class FiniteStateMachine(State): """ @@ -349,7 +346,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() @@ -414,7 +411,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. @@ -470,9 +467,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] @@ -490,8 +487,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) @@ -513,9 +510,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) diff --git a/tutorius/creator.py b/tutorius/creator.py index 513e312..46d4852 100644 --- a/tutorius/creator.py +++ b/tutorius/creator.py @@ -207,9 +207,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() diff --git a/tutorius/dbustools.py b/tutorius/dbustools.py new file mode 100644 index 0000000..ce28d98 --- /dev/null +++ b/tutorius/dbustools.py @@ -0,0 +1,24 @@ +import logging + +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): + logging.debug("Unhandled asynchronous dbus call response with arguments: %s", str(args)) + +def logError(error): + logging.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: + return reply_cb(callable(*args)) + 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 aa8c997..430b708 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,14 @@ class EventFilter(properties.TPropContainer): Base class for an event filter """ - next_state = properties.TStringProperty("None") - def __init__(self, next_state=None): """ 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 +73,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/properties.py b/tutorius/properties.py index abf76e5..b1c6361 100644 --- a/tutorius/properties.py +++ b/tutorius/properties.py @@ -95,6 +95,27 @@ class TPropContainer(object): """ return object.__getattribute__(self, "_props").keys() + # Providing the hash methods necessary to use TPropContainers + # in a dictionary, according to their properties + def __hash__(self): + try: + #Return a hash of properties (key, value) sorted by key + return hash(tuple(map(tuple,sorted(self._props.items(), cmp=lambda x, y: cmp(x[0], y[0]))))) + except TypeError: + #FIXME For list properties (and maybe others), hashing will fail, fallback to id + return id(self) + + def __eq__(self, e2): + return self._props.items() == e2._props.items() + + # 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 : diff --git a/tutorius/service.py b/tutorius/service.py new file mode 100644 index 0000000..c52b7cd --- /dev/null +++ b/tutorius/service.py @@ -0,0 +1,78 @@ +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): + remote_call(self._service.launch, (tutorialID, ), block=False) + + def stop(self): + remote_call(self._service.stop, (), block=False) + + def pause(self): + 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. """ -- cgit v0.9.1