From 0db118e6320d157748fad0b4d7a31beebcf5b301 Mon Sep 17 00:00:00 2001 From: mike Date: Wed, 04 Nov 2009 15:20:30 +0000 Subject: Merge branch 'master' of git://git.sugarlabs.org/tutorius/mainline --- diff --git a/addons/bubblemessage.py b/addons/bubblemessage.py index 2bd2d31..6572a6a 100644 --- a/addons/bubblemessage.py +++ b/addons/bubblemessage.py @@ -13,10 +13,10 @@ # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from sugar.tutorius.actions import Action, DragWrapper -from sugar.tutorius.properties import TStringProperty, TArrayProperty -from sugar.tutorius import overlayer -from sugar.tutorius.services import ObjectStore +from ..actions import Action, DragWrapper +from ..properties import TStringProperty, TArrayProperty +from .. import overlayer +from ..services import ObjectStore class BubbleMessage(Action): message = TStringProperty("Message") diff --git a/addons/chainaction.py b/addons/chainaction.py index 43c4fa4..8df7ac8 100644 --- a/addons/chainaction.py +++ b/addons/chainaction.py @@ -14,7 +14,7 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from sugar.tutorius.actions import * +from ..actions import * class ChainAction(Action): actions = TAddonListProperty() diff --git a/addons/clickaction.py b/addons/clickaction.py index 828dd75..88c5519 100644 --- a/addons/clickaction.py +++ b/addons/clickaction.py @@ -14,8 +14,8 @@ # 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 * +from .. import gtkutils +from ..actions import * class ClickAction(Action): """ diff --git a/addons/dialogmessage.py b/addons/dialogmessage.py index f15f256..9250693 100644 --- a/addons/dialogmessage.py +++ b/addons/dialogmessage.py @@ -16,7 +16,7 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from sugar.tutorius.actions import * +from ..actions import * class DialogMessage(Action): message = TStringProperty("Message") diff --git a/addons/disablewidget.py b/addons/disablewidget.py index ce3f235..fd88303 100644 --- a/addons/disablewidget.py +++ b/addons/disablewidget.py @@ -14,9 +14,9 @@ # 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 +from ..actions import * +from .. import gtkutils +from ..services import ObjectStore class DisableWidgetAction(Action): target = TStringProperty("0") diff --git a/addons/gtkwidgeteventfilter.py b/addons/gtkwidgeteventfilter.py index 5497af4..65aa744 100644 --- a/addons/gtkwidgeteventfilter.py +++ b/addons/gtkwidgeteventfilter.py @@ -13,16 +13,16 @@ # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from sugar.tutorius.filters import EventFilter -from sugar.tutorius.properties import TUAMProperty, TStringProperty -from sugar.tutorius.gtkutils import find_widget +from ..filters import EventFilter +from ..properties import TUAMProperty, TEventType +from ..gtkutils import find_widget class GtkWidgetEventFilter(EventFilter): """ Basic Event filter for Gtk widget events """ object_id = TUAMProperty() - event_name = TStringProperty("clicked") + event_name = TEventType('clicked') def __init__(self, object_id=None, event_name=None): """Constructor @@ -64,6 +64,6 @@ __event__ = { "display_name" : "GTK Event catcher", "icon" : "player_play", "class" : GtkWidgetEventFilter, - "mandatory_props" : ["object_id"] + "mandatory_props" : ["object_id", "event_name"] } diff --git a/addons/gtkwidgettypefilter.py b/addons/gtkwidgettypefilter.py index 816a754..4ffecb5 100644 --- a/addons/gtkwidgettypefilter.py +++ b/addons/gtkwidgettypefilter.py @@ -14,10 +14,10 @@ # 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 +from ..filters import * +from ..properties import * +from ..services import ObjectStore +from ..gtkutils import find_widget import logging logger = logging.getLogger("GtkWidgetTypeFilter") diff --git a/addons/oncewrapper.py b/addons/oncewrapper.py index 97f4752..5db3b60 100644 --- a/addons/oncewrapper.py +++ b/addons/oncewrapper.py @@ -14,7 +14,7 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from sugar.tutorius.actions import * +from ..actions import * class OnceWrapper(Action): """ diff --git a/addons/readfile.py b/addons/readfile.py index 0d276b9..9fe2f81 100644 --- a/addons/readfile.py +++ b/addons/readfile.py @@ -16,9 +16,9 @@ import os -from sugar.tutorius.actions import Action -from sugar.tutorius.properties import TFileProperty -from sugar.tutorius.services import ObjectStore +from ..actions import Action +from ..properties import TFileProperty +from ..services import ObjectStore class ReadFile(Action): filename = TFileProperty(None) diff --git a/addons/timerevent.py b/addons/timerevent.py index c7374d0..752a865 100644 --- a/addons/timerevent.py +++ b/addons/timerevent.py @@ -16,8 +16,8 @@ import gobject -from sugar.tutorius.filters import EventFilter -from sugar.tutorius.properties import TIntProperty +from ..filters import EventFilter +from ..properties import TIntProperty class TimerEvent(EventFilter): """ diff --git a/addons/triggereventfilter.py b/addons/triggereventfilter.py index 06c0995..19544b0 100644 --- a/addons/triggereventfilter.py +++ b/addons/triggereventfilter.py @@ -14,8 +14,8 @@ # 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 ..filters import * +from ..properties import * class TriggerEventFilter(EventFilter): """ @@ -23,8 +23,8 @@ class TriggerEventFilter(EventFilter): Used to fake events and see the effect on the FSM. """ - def __init__(self, next_state): - EventFilter.__init__(self, next_state) + def __init__(self): + EventFilter.__init__(self) self.toggle_on_callback = False def install_handlers(self, callback, **kwargs): @@ -41,6 +41,6 @@ __event__ = { 'display_name' : 'Triggerable event filter (test only)', 'icon' : '', 'class' : TriggerEventFilter, - 'mandatory_props' : ['next_state'], + 'mandatory_props' : [], 'test' : True } diff --git a/addons/typetextaction.py b/addons/typetextaction.py index fee66e5..8b746e6 100644 --- a/addons/typetextaction.py +++ b/addons/typetextaction.py @@ -14,8 +14,8 @@ # 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 ..actions import * +from .. import gtkutils class TypeTextAction(Action): """ diff --git a/addons/widgetidentifyaction.py b/addons/widgetidentifyaction.py index 3c66211..3df244b 100644 --- a/addons/widgetidentifyaction.py +++ b/addons/widgetidentifyaction.py @@ -14,9 +14,9 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from sugar.tutorius.actions import * +from ..actions import * -from sugar.tutorius.editor import WidgetIdentifier +from ..editor import WidgetIdentifier class WidgetIdentifyAction(Action): def __init__(self): diff --git a/data/icons/clock.svg b/data/icons/clock.svg index 8adb898..dc73bbb 100644 --- a/data/icons/clock.svg +++ b/data/icons/clock.svg @@ -1,6 +1,6 @@ - + @@ -266,4 +266,4 @@ - \ No newline at end of file + diff --git a/data/ui/creator.glade b/data/ui/creator.glade new file mode 100644 index 0000000..1c9669d --- /dev/null +++ b/data/ui/creator.glade @@ -0,0 +1,209 @@ + + + + + + 300 + 500 + Toolbox + False + center-on-parent + 200 + 500 + True + True + True + False + False + + + + True + vertical + 5 + + + True + 5 + start + + + gtk-save + True + True + True + True + + + + False + False + 0 + + + + + gtk-quit + True + True + True + True + + + + False + False + 1 + + + + + False + False + 0 + + + + + True + True + never + automatic + in + + + True + queue + + + True + vertical + + + True + True + True + + + True + True + 2 + 0 + 0 + 0 + + + + + + True + actions + + + label_item + + + + + False + 0 + + + + + True + True + True + + + True + True + 2 + 0 + 0 + 0 + + + + + + True + events + + + label_item + + + + + False + False + 1 + + + + + + + + + 1 + + + + + True + vertical + 10 + + + + + + False + 5 + 2 + + + + + True + 5 + start + + + gtk-media-record + True + True + True + + + False + False + 0 + + + + + gtk-media-stop + True + True + True + + + False + False + 1 + + + + + False + False + 3 + + + + + + diff --git a/setup.py b/setup.py index 9362dc7..e33873c 100755 --- a/setup.py +++ b/setup.py @@ -93,13 +93,17 @@ setup(name='Tutorius', 'sugar.tutorius', 'sugar.tutorius.uam', 'sugar.tutorius.addons', + 'sugar.tutorius.apilib', + 'sugar.tutorius.apilib.httplib2', ], package_dir={ 'sugar.tutorius': 'tutorius', 'sugar.tutorius.addons': 'addons', }, cmdclass = {'test': TestCommand}, - data_files=[('share/icons/sugar/scalable/actions', glob.glob('data/icons/*.svg')),] + data_files=[('share/icons/sugar/scalable/actions', glob.glob('data/icons/*.svg')), + ('share/tutorius/ui', glob.glob('data/ui/*.glade')), + ] ) # vim: set et sw=4 sts=4 ts=4: diff --git a/tests/coretests.py b/tests/coretests.py index 4f564c8..b9e04e5 100644 --- a/tests/coretests.py +++ b/tests/coretests.py @@ -28,7 +28,7 @@ and event filters. Those are in their separate test module import unittest -import copy +from copy import deepcopy import logging from sugar.tutorius.actions import * from sugar.tutorius.addon import * @@ -130,9 +130,9 @@ class StateTest(unittest.TestCase): Tests the fact that the event filters are correctly installed on setup and uninstalled on teardown. """ - event_filter = addon.create('TriggerEventFilter', "second_state") + event_filter = addon.create('TriggerEventFilter') - state = State("event_test", event_filter_list=[event_filter]) + state = State("event_test", event_filter_list=[(event_filter, "second_state")]) state.set_tutorial(SimpleTutorial()) assert event_filter.toggle_on_callback == False, "Wrong init of event_filter" @@ -198,22 +198,21 @@ class StateTest(unittest.TestCase): def test_add_event_filter(self): state = State("INIT") - event1 = addon.create('TriggerEventFilter', "s") - event2 = addon.create('TriggerEventFilter', "t") - event3 = addon.create('TriggerEventFilter', "r") + event1 = addon.create('TriggerEventFilter') + event2 = addon.create('TriggerEventFilter') # Insert the event filters - assert state.add_event_filter(event1), "Could not add event filter 1" - assert state.add_event_filter(event2), "Could not add event filter 2" - assert state.add_event_filter(event3), "Could not add event filter 3" + assert state.add_event_filter(event1, "s"), "Could not add event filter 1" # Make sure we cannot insert an event twice - assert state.add_event_filter(event1) == False, "Could add twice the event filter" + assert state.add_event_filter(event1, "s") == False, "Could add twice the event filter" + assert state.add_event_filter(event2, "t") == False, "Could add event filter 2" # Get the list of event filters - event_filters = state.get_event_filter_list() + event_filters = map(lambda x: x[0],state.get_event_filter_list()) - assert event1 in event_filters and event2 in event_filters and event3 in event_filters, \ + #even if we added only the event 1, they are equivalent + assert event1 in event_filters and event2 in event_filters, \ "The event filters were not all added inside the state" # Clear the list @@ -243,19 +242,19 @@ class StateTest(unittest.TestCase): 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") + event1 = addon.create("GtkWidgetEventFilter", "0.0.0.1.1.2.3.1", "clicked") act3 = addon.create("DialogMessage", message="Hello again.", position=[200, 400]) # Build the first state st1.add_action(act1) st1.add_action(act3) - st1.add_event_filter(event1) + st1.add_event_filter(event1, "nextState") # Build the second state st2.add_action(act2) st2.add_action(act3) - st2.add_event_filter(event1) + st2.add_event_filter(event1, "nextState") # Make sure that they are identical for now assert st1 == st2, "States should be considered as identical" @@ -276,22 +275,21 @@ class StateTest(unittest.TestCase): assert not(st1 == st2), "Different state names should give different states" st2.name = "Identical" - st3 = copy.deepcopy(st1) + st3 = deepcopy(st1) st3.add_action(addon.create("BubbleMessage", "Hi!", [128,264])) assert not (st1 == st3), "States having a different number of actions should be different" - st4 = copy.deepcopy(st1) - st4.add_event_filter(addon.create("GtkWidgetEventFilter", "next_state", "0.0.1.1.2.2.3", "clicked")) + st4 = deepcopy(st1) + st4.add_event_filter(addon.create("GtkWidgetEventFilter", "0.0.1.1.2.2.3", "clicked"), "next_state") assert not (st1 == st4), "States having a different number of events should be different" - st5 = copy.deepcopy(st1) + st5 = deepcopy(st1) st5._event_filters = [] - st5.add_event_filter(addon.create("GtkWidgetEventFilter", "other_state", "0.1.2.3.4.1.2", "pressed")) + st5.add_event_filter(addon.create("GtkWidgetEventFilter", "0.1.2.3.4.1.2", "pressed"), "other_state") - #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" @@ -305,10 +303,10 @@ class FSMTest(unittest.TestCase): act_init = TrueWhileActiveAction() act_second = TrueWhileActiveAction() - event_init = FakeEventFilter("SECOND") + event_init = FakeEventFilter() content = { - "INIT": State("INIT", action_list=[act_init],event_filter_list=[event_init]), + "INIT": State("INIT", action_list=[act_init],event_filter_list=[(event_init,"SECOND")]), "SECOND": State("SECOND", action_list=[act_second]) } @@ -399,9 +397,9 @@ class FSMTest(unittest.TestCase): This test removes a state from the FSM. It also verifies that the links from other states going into the removed state are gone. """ - st1 = State("INIT", event_filter_list=[addon.create('TriggerEventFilter', "second")]) - st2 = State("second", event_filter_list=[addon.create('TriggerEventFilter', "third")]) - st3 = State("third", event_filter_list=[addon.create('TriggerEventFilter', "second")]) + st1 = State("INIT", event_filter_list=[(addon.create('TriggerEventFilter'), "second")]) + st2 = State("second", event_filter_list=[(addon.create('TriggerEventFilter'), "third")]) + st3 = State("third", event_filter_list=[(addon.create('TriggerEventFilter'), "second")]) fsm = FiniteStateMachine("StateRemovalTest") @@ -524,7 +522,7 @@ class FSMTest(unittest.TestCase): fsm.add_action(act1) - fsm2 = copy.deepcopy(fsm) + fsm2 = deepcopy(fsm) assert fsm == fsm2 @@ -549,7 +547,7 @@ class FSMTest(unittest.TestCase): fsm.add_state(st1) fsm.add_state(st2) - fsm4 = copy.deepcopy(fsm) + fsm4 = deepcopy(fsm) assert fsm == fsm4 @@ -582,13 +580,13 @@ class FSMExplorationTests(unittest.TestCase): """ st1 = State("INIT") st1.add_action(CountAction()) - st1.add_event_filter(addon.create('TriggerEventFilter', "Second")) - st1.add_event_filter(addon.create('TriggerEventFilter', "Third")) + st1.add_event_filter(addon.create('TriggerEventFilter'), "Second") + st1.add_event_filter(addon.create('TriggerEventFilter'), "Third") st2 = State("Second") st2.add_action(TrueWhileActiveAction()) - st2.add_event_filter(addon.create('TriggerEventFilter', "Third")) - st2.add_event_filter(addon.create('TriggerEventFilter', "Fourth")) + st2.add_event_filter(addon.create('TriggerEventFilter'), "Third") + st2.add_event_filter(addon.create('TriggerEventFilter'), "Fourth") st3 = State("Third") st3.add_action(CountAction()) diff --git a/tests/filterstests.py b/tests/filterstests.py index c45f924..ee6033b 100644 --- a/tests/filterstests.py +++ b/tests/filterstests.py @@ -32,20 +32,10 @@ from gtkutilstests import SignalCatcher class BaseEventFilterTests(unittest.TestCase): """Test the behavior of the Base EventFilter class""" - def test_properties(self): - """Test EventFilter properties""" - e = EventFilter("NEXTSTATE") - - assert e.next_state == "NEXTSTATE", "next_state should have value used in constructor" - - e.next_state = "NEWSTATE" - - assert e.next_state == "NEWSTATE", "next_state should have been changed by setter" - def test_callback(self): """Test the callback mechanism""" - e = EventFilter("Next") + e = EventFilter() s = SignalCatcher() #Trigger the do_callback, shouldn't do anything @@ -79,7 +69,7 @@ class TestTimerEvent(unittest.TestCase): ctx = gobject.MainContext() main = gobject.MainLoop(ctx) - e = addon.create('TimerEvent', "Next", 2) # 2 seconds should be enough :s + e = addon.create('TimerEvent', 2) # 2 seconds should be enough :s s = SignalCatcher() e.install_handlers(s.callback) @@ -122,7 +112,7 @@ class TestTimerEvent(unittest.TestCase): ctx = gobject.MainContext() main = gobject.MainLoop(ctx) - e = addon.create('TimerEvent', "Next", 2) # 2 seconds should be enough :s + e = addon.create('TimerEvent', 2) # 2 seconds should be enough :s s = SignalCatcher() e.install_handlers(s.callback) @@ -169,7 +159,7 @@ class TestGtkWidgetEventFilter(unittest.TestCase): self.top.add(self.btn1) def test_install(self): - h = addon.create('GtkWidgetEventFilter', "Next","0","whatever") + h = addon.create('GtkWidgetEventFilter', "0","whatever") try: h.install_handlers(None) @@ -178,7 +168,7 @@ class TestGtkWidgetEventFilter(unittest.TestCase): assert True, "Install should have failed" def test_button_clicks(self): - h = addon.create('GtkWidgetEventFilter', "Next","0.0","clicked") + h = addon.create('GtkWidgetEventFilter', "0.0","clicked") s = SignalCatcher() h.install_handlers(s.callback, activity=self.top) diff --git a/tests/linear_creatortests.py b/tests/linear_creatortests.py index 999f4d5..e3c30c1 100644 --- a/tests/linear_creatortests.py +++ b/tests/linear_creatortests.py @@ -35,11 +35,11 @@ class CreatorTests(unittest.TestCase): creator.action(CountAction()) creator.action(CountAction()) - creator.event(TriggerEventFilter("Not important")) + creator.event(TriggerEventFilter()) creator.action(CountAction()) - creator.event(TriggerEventFilter("Not good either...")) + creator.event(TriggerEventFilter()) fsm = creator.generate_fsm() @@ -50,13 +50,13 @@ class CreatorTests(unittest.TestCase): assert len(init_state.get_action_list()) == 2, "Creator did not insert all the actions" - assert init_state.get_event_filter_list()[0].get_next_state() == "State 1" , "expected next state to be 'State 1' but got %s" % init_state.get_event_filter_list()[0].get_next_state() + assert init_state.get_event_filter_list()[0][1] == "State 1" , "expected next state to be 'State 1' but got %s" % init_state.get_event_filter_list()[0][1] state1 = fsm.get_state_by_name("State 1") assert len(state1.get_action_list()) == 1, "Creator did not insert all the actions" - assert state1.get_event_filter_list()[0].get_next_state() == "State 2" + assert state1.get_event_filter_list()[0][1] == "State 2" # Make sure we have the final state and that it's empty state2 = fsm.get_state_by_name("State2") diff --git a/tests/probetests.py b/tests/probetests.py index a440334..e1a587b 100644 --- a/tests/probetests.py +++ b/tests/probetests.py @@ -19,45 +19,482 @@ Probe Tests """ import unittest -import os, sys -import gtk -import time +import pickle from dbus.mainloop.glib import DBusGMainLoop +from dbus.mainloop import NULL_MAIN_LOOP import dbus -from sugar.tutorius.TProbe import TProbe, ProbeProxy +from sugar.tutorius.TProbe import TProbe, ProbeProxy, ProbeManager +from sugar.tutorius import addon +from sugar.tutorius.actions import Action +from sugar.tutorius.properties import TIntProperty, TStringProperty -class FakeActivity(object): - def __init__(self): - self.top = gtk.Window(type=gtk.WINDOW_TOPLEVEL) - self.top.set_name("Top") +#Create a substitute addon create function +old_addon_create = addon.create +fake_addon_cache = {} +def new_addon_create(name, *args, **kwargs): + if name in fake_addon_cache: + return fake_addon_cache[name](*args, **kwargs) + else: + return old_addon_create(name, *args, **kwargs) + +message_box = None +event_box = None + +class MockAddon(Action): + i = TIntProperty(0) + s = TStringProperty("test") + + def do(self): + global message_box + message_box = (self.i, self.s) + + def undo(self): + global message_box + message_box = None + + def install_handlers(self, callback, **kwargs): + global message_box + message_box = callback + + def remove_handlers(self): + global message_box + message_box = None + +fake_addon_cache["MockAddon"] = MockAddon + +class MockActivity(object): + pass + +class MockProbeProxy(object): + _MockProxyCache = {} + def __new__(cls, activityName): + #For testing, use only one instance per activityName + return cls._MockProxyCache.setdefault(activityName, super(MockProbeProxy, cls).__new__(cls)) + + def __init__(self, activityName): + """ + Constructor + @param activityName unique activity id. Must be a valid dbus bus name. + """ + self.MockAction = None + self.MockActionUpdate = None + self.MockEvent = None + self.MockCB = None + self.MockAlive = True + self.MockEventAddr = None - hbox = gtk.HBox() - self.top.add(hbox) - hbox.show() + def isAlive(self): + return self.MockAlive + + def install(self, action, block=False): + self.MockAction = action + self.MockActionUpdate = None + return None + + def update(self, action, newaction, block=False): + self.MockAction = action + self.MockActionUpdate = newaction + return None - btn1 = gtk.Button() - btn1.set_name("Button1") - hbox.pack_start(btn1) - btn1.show() - self.button = btn1 + def uninstall(self, action, block=False): + self.MockAction = None + self.MockActionUpdate = None + return None + def subscribe(self, event, callback, block=True): + #Do like the current Probe + if not block: + raise RuntimeError("This function does not allow non-blocking mode yet") + + self.MockEvent= event + self.MockCB = callback + return str(id(event)) + + def unsubscribe(self, address, block=True): + self.MockEventAddr = address + return None + + def detach(self, block=False): + self.MockAction = None + self.MockActionUpdate = None + self.MockEvent = None + self.MockCB = None + self.MockAlive = False + self.MockEventAddr = None + return None + +class MockProxyObject(object): + _MockProxyObjects = {} + def __new__(cls, name, path): + return cls._MockProxyObjects.setdefault((name, path), super(MockProxyObject, cls).__new__(cls)) + + def __init__(self, name, path): + self.MockCall = {} + self.MockRet = {} + self.MockCB = {} + + def get_dbus_method(self, name, *args, **kwargs): + #FIXME This mockMethod should support asynchronous calling, + # and possibly more + def mockMethod(*a, **kw): + self.MockCall[name] = dict(args=a, kwargs=kw) + return self.MockRet.get(name, None) + return mockMethod + + def connect_to_signal(self, signal_name, handler_function, dbus_interface=None, **kw): + self.MockCB[signal_name] = dict(handler_function=handler_function, dbus_interface=dbus_interface, **kw) + +class MockSessionBus(object): + def get_object(self, bus_name, object_path, introspect=True, follow_name_owner_changes=False, **kwargs): + return MockProxyObject(bus_name, object_path) + +old_SessionBus = dbus.SessionBus + +########################################################################### +# Begin Test Cases +########################################################################### class ProbeTest(unittest.TestCase): - def test_ping(self): + def setUp(self): + global message_box + message_box = None + + #Fix the addon create + addon.create = new_addon_create + + #Set a default dbus mainloop m = DBusGMainLoop(set_as_default=True) dbus.set_default_main_loop(m) - activity = FakeActivity() - probe = TProbe("localhost.unittest.ProbeTest", activity.top) + #Setup the activity and probe + self.activity = MockActivity() + self.probe = TProbe("localhost.unittest.ProbeTest", self.activity) - #Parent, ping the probe - proxy = ProbeProxy("localhost.unittest.ProbeTest") - res = probe.ping() - + #Override the eventOccured on the Probe... + self.old_eO = self.probe.eventOccured + def newEo(event): + global event_box + try: + self.old_eO(event) + event_box = event + except RuntimeError: + event_box = None + + self.probe.eventOccured = newEo + + def tearDown(self): + #Replace addon create + addon.create = old_addon_create + + #Clear the default dbus mainloop + dbus.set_default_main_loop(NULL_MAIN_LOOP) + + #Clear the activity + self.probe.remove_from_connection() + del self.probe + del self.activity + + def test_ping(self): + #Test ping() + res = self.probe.ping() assert res == "alive", "Probe should be alive" + def test_action(self): + global message_box + action = MockAddon() + action.i, action.s = (5,"woot") + + assert message_box is None, "Message box should still be empty" + + #install 1 + address = self.probe.install(pickle.dumps(action)) + assert type(address) == str, "install should return a string" + assert message_box == (5, "woot"), "message box should have (i, s)" + + #install 2 + action.i, action.s = (10, "ahhah!") + address2 = self.probe.install(pickle.dumps(action)) + assert message_box == (10, "ahhah!"), "message box should have changed" + assert address != address2, "action addresses should be different" + + #uninstall 2 + self.probe.uninstall(address2) + assert message_box is None, "undo should clear the message box" + + #update action 1 with action 2 props + self.probe.update(address, pickle.dumps(action._props)) + assert message_box == (10, "ahhah!"), "message box should have changed(i, s)" + + #ErrorCase: Update with bad address + #try to update 2, should fail + self.assertRaises(KeyError, self.probe.update, address2, pickle.dumps(action._props)) + + self.probe.uninstall(address) + assert message_box is None, "undo should clear the message box" + + message_box = "Test" + #ErrorCase: Uninstall bad address (currently silent fail) + #Uninstall twice should do nothing + self.probe.uninstall(address) + assert message_box == "Test", "undo should not have happened again" + + def test_events(self): + global message_box + global event_box + + event = MockAddon() + event.i, event.s = (0, "event1") + event2 = MockAddon() + event2.i, event2.s = (1, "event2") + + addr = self.probe.subscribe(pickle.dumps(event)) + cb1 = message_box + addr2 = self.probe.subscribe(pickle.dumps(event2)) + cb2 = message_box + assert type(addr) == str, "should return a string address" + assert addr != addr2, "each subscribe should return a different address" + + assert event_box is None, "event_box should still be empty" + #Do the callback 2 + cb2() + + assert event_box is not None, "event_box should have an event" + + assert type(event_box) == str, "event should be pickled" + assert pickle.loads(event_box) == event2, "event should be event2" + + #Unsubscribe event 2 + self.probe.unsubscribe(addr2) + assert message_box is None, "unsubscribe should clear the message_box" + + #Do the callback 1 + cb1() + assert pickle.loads(event_box) == event, "event should be event1" + + #unsubscribe event 1 + self.probe.unsubscribe(addr) + assert message_box is None, "unsubscribe should clear the message_box" + + event_box = None + #ErrorCase: callback called from unregistered event filter + #Do the callback 1 again + self.assertRaises(RuntimeWarning, cb1) + +class ProbeManagerTest(unittest.TestCase): + def setUp(self): + MockProbeProxy._MockProxyCache = {} + self.probeManager = ProbeManager(proxy_class=MockProbeProxy) + + def test_attach(self): + #ErrorCase: Set currentActivity to unattached activity + #Attempt to set to a non existing activity + try: + self.probeManager.currentActivity = "act1" + assert False, "Exception expected" + except RuntimeError, e: + pass + + #Attach an activity + self.probeManager.attach("act1") + + #Should have been created + assert "act1" in MockProbeProxy._MockProxyCache.keys(), "Proxy not created" + + #ErrorCase: Attach multiple times to same activity + #Try to attach again + self.assertRaises(RuntimeWarning, self.probeManager.attach, "act1") + + #Set current activity should work + self.probeManager.currentActivity = "act1" + + #TODO Fill in the alive/notalive behavior at creation time once + # it is fixed in the ProbeManager + + def test_detach(self): + #attach an activity + self.probeManager.attach("act1") + self.probeManager.currentActivity = "act1" + act1 = MockProbeProxy("act1") + + #Now we detach + self.probeManager.detach("act1") + assert act1.MockAlive == False, "ProbeProxy should have been detached" + assert self.probeManager.currentActivity is None, "Current activity should be None" + + #Attempt to detach again, should do nothing + #ErrorCase: detach already detached (currently silent fail) + self.probeManager.detach("act1") + + #Now, attach 2 activities + self.probeManager.attach("act2") + self.probeManager.attach("act3") + act2 = MockProbeProxy("act2") + act3 = MockProbeProxy("act3") + + self.probeManager.currentActivity = "act2" + + assert act2.MockAlive and act3.MockAlive, "Both ProbeProxy instances should be alive" + + #Detach the not active activity + self.probeManager.detach("act3") + #Check the statuses + assert act2.MockAlive and not act3.MockAlive, "Only act2 should be alive" + assert self.probeManager.currentActivity == "act2", "act2 should not have failed" + + def test_actions(self): + self.probeManager.attach("act1") + self.probeManager.attach("act2") + act1 = MockProbeProxy("act1") + act2 = MockProbeProxy("act2") + + ad1 = MockAddon() + #ErrorCase: install, update, uninstall without currentActivity + #Action functions should do a warning if there is no activity + self.assertRaises(RuntimeWarning, self.probeManager.install, ad1) + self.assertRaises(RuntimeWarning, self.probeManager.update, ad1, ad1) + self.assertRaises(RuntimeWarning, self.probeManager.uninstall, ad1) + assert act1.MockAction is None, "Action should not be installed on inactive proxy" + assert act2.MockAction is None, "Action should not be installed on inactive proxy" + + self.probeManager.currentActivity = "act1" + self.probeManager.install(ad1) + assert act1.MockAction == ad1, "Action should have been installed" + assert act2.MockAction is None, "Action should not be installed on inactive proxy" + + self.probeManager.update(ad1, ad1) + assert act1.MockActionUpdate == ad1, "Action should have been updated" + assert act2.MockActionUpdate is None, "Should not update on inactive" + + self.probeManager.currentActivity = "act2" + self.probeManager.uninstall(ad1) + assert act1.MockAction == ad1, "Action should still be installed" + + self.probeManager.currentActivity = "act1" + self.probeManager.uninstall(ad1) + assert act1.MockAction is None, "Action should be uninstalled" + + def test_events(self): + self.probeManager.attach("act1") + self.probeManager.attach("act2") + act1 = MockProbeProxy("act1") + act2 = MockProbeProxy("act2") + + ad1 = MockAddon() + ad2 = MockAddon() + ad2.i, ad2.s = (2, "test2") + + cb1 = lambda *args: None + cb2 = lambda *args: None + + #ErrorCase: unsubscribe and subscribe without current activity + #Event functions should do a warning if there is no activity + self.assertRaises(RuntimeWarning, self.probeManager.subscribe, ad1, cb1) + self.assertRaises(RuntimeWarning, self.probeManager.unsubscribe, None) + assert act1.MockEvent is None, "No event should be on act1" + assert act2.MockEvent is None, "No event should be on act2" + + self.probeManager.currentActivity = "act1" + self.probeManager.subscribe(ad1, cb1) + assert act1.MockEvent == ad1, "Event should have been installed" + assert act1.MockCB == cb1, "Callback should have been set" + assert act2.MockEvent is None, "No event should be on act2" + + self.probeManager.unsubscribe("SomeAddress") + assert act1.MockEventAddr == "SomeAddress", "Unsubscribe should have been called" + assert act2.MockEventAddr is None, "Unsubscribe should not have been called" + +class ProbeProxyTest(unittest.TestCase): + def setUp(self): + dbus.SessionBus = MockSessionBus + + self.mockObj = MockProxyObject("unittest.TestCase", "/tutorius/Probe") + self.probeProxy = ProbeProxy("unittest.TestCase") + + def tearDown(self): + dbus.SessionBus = old_SessionBus + MockProxyObject._MockProxyObjects = {} + + def test_Alive(self): + self.mockObj.MockRet["ping"] = "alive" + assert self.probeProxy.isAlive() == True, "Alive should return True" + + self.mockObj.MockRet["ping"] = "anything else" + assert self.probeProxy.isAlive() == False, "Alive should return False" + + def test_actions(self): + action = MockAddon() + action.i, action.s = 5, "action" + action2 = MockAddon() + action2.i, action2.s = 10, "action2" + + #Check if the installed action is the good one + address = "Addr1" + #Set the return value of probe install + self.mockObj.MockRet["install"] = address + self.probeProxy.install(action, block=True) + assert pickle.loads(self.mockObj.MockCall["install"]["args"][0]) == action, "1 argument, the action" + + #ErrorCase: Update should fail on noninstalled actions + self.assertRaises(RuntimeWarning, self.probeProxy.update, action2, action2, block=True) + + #Test the update + self.probeProxy.update(action, action2, block=True) + args = self.mockObj.MockCall["update"]["args"] + assert args[0] == address, "arg 1 should be the action address" + assert pickle.loads(args[1]) == action2._props, "arg2 should be the new action properties" + + #ErrorCase: Uninstall on not installed action (silent fail) + #Test the uninstall + self.probeProxy.uninstall(action2, block=True) + assert not "uninstall" in self.mockObj.MockCall, "Uninstall should not be called if action is not installed" + + self.probeProxy.uninstall(action, block=True) + assert self.mockObj.MockCall["uninstall"]["args"][0] == address, "1 argument, the action address" + + def test_events(self): + event = MockAddon() + event.i, event.s = 5, "event" + event2 = MockAddon() + event2.i, event2.s = 10, "event2" + + def callback(event): + global message_box + message_box = event + + #Check if the installed event is the good one + address = "Addr1" + #Set the return value of probe subscribe + self.mockObj.MockRet["subscribe"] = address + self.probeProxy.subscribe(event, callback, block=True) + assert pickle.loads(self.mockObj.MockCall["subscribe"]["args"][0]) == event, "1 argument, the event" + + #Call the callback with the event + global message_box + self.mockObj.MockCB["eventOccured"]["handler_function"](pickle.dumps(event)) + assert message_box == event, "callback should have been called with event" + message_box = None + + #ErrorCase: eventOccured triggered by a wrong event + #Call with a wrong event + self.mockObj.MockCB["eventOccured"]["handler_function"](pickle.dumps(event2)) + assert message_box is None, "callback should not have been called" + + + #ErrorCase: unsubcribe for non subscribed event + #Test the unsubscribe + self.probeProxy.unsubscribe("otheraddress", block=True) + assert not "unsubscribe" in self.mockObj.MockCall, "Unsubscribe should not be called if event is not subscribeed" + + self.probeProxy.unsubscribe(address, block=True) + assert self.mockObj.MockCall["unsubscribe"]["args"][0] == address, "1 argument, the event address" + + #ErrorCase: eventOccured triggered by uninstalled event + #Test the callback with unregistered event + self.mockObj.MockCB["eventOccured"]["handler_function"](pickle.dumps(event)) + assert message_box is None, "callback should not have been called" + if __name__ == "__main__": unittest.main() diff --git a/tests/skip b/tests/skip index 882d14d..3868383 100644 --- a/tests/skip +++ b/tests/skip @@ -1,3 +1,4 @@ +utils.py run-tests.py overlaytests.py viewer.py diff --git a/tests/storetests.py b/tests/storetests.py index da20c00..1752fe6 100644 --- a/tests/storetests.py +++ b/tests/storetests.py @@ -15,93 +15,126 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA import unittest +from tests.utils import skip, catch_unimplemented +import random from sugar.tutorius.store import * -g_tutorial_id = '114db454-b2a1-11de-8cfc-001f5bf747dc' -g_other_id = '47efc6ee-b2a3-11de-8cfc-001f5bf747dc' +g_tutorial_id = '4079' +g_other_id = '4080' class StoreProxyTest(unittest.TestCase): def setUp(self): - self.store = StoreProxy() + self.store = StoreProxy("http://bobthebuilder.mine.nu/tutorius/en-US/tutorius") def tearDown(self): pass + @catch_unimplemented def test_get_categories(self): categories = self.store.get_categories() assert isinstance(categories, list), "categories should be a list" + @catch_unimplemented def test_get_tutorials(self): self.store.get_tutorials() - def test_get_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) + @catch_unimplemented def test_download_tutorial(self): - tutorial = self.store.download_tutorial(g_tutorial_id) + tutorial = self.store.download_tutorial(g_other_id) assert tutorial is not None + @catch_unimplemented def test_login(self): - assert self.store.login("unknown_user", "random_password") + assert self.store.login("benoit.tremblay1@gmail.com", "tutorius12") + @catch_unimplemented def test_register_new_user(self): + random_num = str(random.randint(0, 999999999)) user_info = { - 'name' : "Albert", - 'last_name' : "The Tester", - 'location' : 'Mozambique', - 'email' : 'albertthetester@mozambique.org' + 'nickname' : "Albert%s" % (random_num), + 'password' : "tutorius12", + 'email' : 'albertthetester%s@mozambique.org' % (random_num) } assert self.store.register_new_user(user_info) class StoreProxyLoginTest(unittest.TestCase): + @catch_unimplemented def setUp(self): - self.store = StoreProxy() - self.store.login("unknown_user", "random_password") + self.store = StoreProxy("http://bobthebuilder.mine.nu/tutorius/en-US/tutorius") + self.store.login("nobody@mozilla.org", "tutorius12") + @catch_unimplemented def tearDown(self): session_id = self.store.get_session_id() if session_id is not None: self.store.close_session() - def test_close_session(self): - assert self.store.close_session() + @catch_unimplemented def test_get_session_id(self): session_id = self.store.get_session_id() assert session_id is not None + @catch_unimplemented def test_rate(self): assert self.store.rate(5, g_tutorial_id) + @catch_unimplemented def test_publish(self): # TODO : We need to send in a real tutorial loaded from # the Vault - assert self.store.publish(['This should be a real tutorial...']) + tutorial_info = { + 'name': 'newtut', + 'summary': 'This is a tutorial', + 'filename': 'test.xml', + 'homepage': 'http://google.com', + 'version': '1', + 'cat1': '17', + 'cat2': '18', + 'cat3': '' + } + assert self.store.publish('This should be a real tutorial...', tutorial_info) + @catch_unimplemented def test_unpublish(self): - # 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) + + # Republish the tutorial + self.store.publish(None, None, g_tutorial_id) + + def test_republish(self): + assert self.store.publish(None, None, g_tutorial_id) + @catch_unimplemented def test_update_published_tutorial(self): # TODO : Run these tests with files from the Vault - self.store.publish([g_tutorial_id, 'Fake tutorial']) + #self.store.publish([g_tutorial_id, 'Fake tutorial']) + + tutorial_info = { + 'name': 'newtut', + 'summary': 'This is an updated tutorial', + 'filename': 'test.xml', + 'homepage': 'http://google.com', + 'version': '2', + 'cat1': '17', + 'cat2': '18', + 'cat3': '' + } - assert self.store.update_published_tutorial(g_tutorial_id, [g_tutorial_id, 'This is an updated tutorial']) + assert self.store.update_published_tutorial(g_tutorial_id, 'This is an updated tutorial', tutorial_info) + + def test_close_session(self): + assert self.store.close_session() diff --git a/tests/tutorialtests.py b/tests/tutorialtests.py new file mode 100644 index 0000000..23d5fc8 --- /dev/null +++ b/tests/tutorialtests.py @@ -0,0 +1,416 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Erick Lavoie +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +Tutorial Tests +""" + +# TODO: Add tests for 47, 52, 55, 109, 132, 175, 209, 229, 233, 271, 274, 292, 295, 318, 337, 375, 394, 411, 428, 446, 480, 491, 624, 637, 698 +import unittest + +from sugar.tutorius.tutorial import * + +# The following tests are organized around 4 classes: +# +# Black box tests: +# Those tests should limit themselves to exercise the +# interface of the object so everything should be tested +# only through the interface the object offers. This will +# ease test maintenance since we anticipate most changes +# will be about the implementation of an object and not +# its interface. +# +# Tests definitions are written assuming the previous tests +# did complete correctly so the number of things to assert +# is minimal. +# +# Basic interface cases: +# Test the interface of the object for trivial cases +# just to assert that the functionality this object +# offers really works +# +# Limit cases: +# Test edge cases that cover more obscure usage +# scenarios but that should be valid nonetheless +# +# Error cases: +# Test wrong inputs to make sure that the object is hard +# to misuse and do generate proper errors +# +# White box tests: +# Those should be used only for really important algorithms +# to make sure they behave correctly in every cases, otherwise +# the tests will break each time we change something in the +# implementation + +from sugar.tutorius.properties import * + +class MockAction(TPropContainer): + i = TIntProperty(0, 0, 9) + +class MockEvent(TPropContainer): + i = TIntProperty(0, 0, 9) + +class StateTest(unittest.TestCase): + """Test basic functionalities of states used by tutorials""" + + def setUp(self): + self.state = State("State1") + self.action = MockAction() + self.event = MockEvent() + + def tearDown(self): + pass + + ######################### Basic interface cases ######################### + + #### Action + def test_add_dummy_action(self): + action_name = self.state.add_action("action1") + assert action_name + assert len(self.state.get_action_dict()) == 1 + assert self.state.get_action_dict().has_key(action_name) + assert self.state.get_action_dict()[action_name] == "action1" + + def test_add_generate_unique_action_names(self): + action_name1 = self.state.add_action("action1") + action_name2 = self.state.add_action("action2") + assert action_name1 and action_name2 + assert action_name1 != action_name2 + + def test_update_dummy_action(self): + action_name = self.state.add_action(self.action) + assert self.action.i == 0 + + prop = self.action.get_properties_dict_copy() + prop["i"] = 2 + self.state.update_action(action_name, prop) + + assert len(self.state.get_action_dict()) == 1 + assert self.state.get_action_dict().has_key(action_name) + assert self.state.get_action_dict()[action_name].get_properties_dict_copy() == prop + + def test_delete_dummy_action(self): + action_name = self.state.add_action("action1") + assert len(self.state.get_action_dict()) == 1 + assert self.state.get_action_dict().has_key(action_name) + assert self.state.get_action_dict()[action_name] == "action1" + + self.state.delete_action(action_name) + assert len(self.state.get_action_dict()) == 0 + + def test_delete_all_dummy_actions(self): + action_name = self.state.add_action("action1") + assert len(self.state.get_action_dict()) == 1 + assert self.state.get_action_dict().has_key(action_name) + assert self.state.get_action_dict()[action_name] == "action1" + + self.state.delete_actions() + assert len(self.state.get_action_dict()) == 0 + + #### Transition + def test_add_dummy_transition(self): + transition_name = self.state.add_transition("transition1") + assert len(self.state.get_transition_dict()) == 1 + assert self.state.get_transition_dict().has_key(transition_name) + assert self.state.get_transition_dict()[transition_name] == "transition1" + + def test_add_generate_unique_transition_names(self): + transition_name1 = self.state.add_transition("transition1") + transition_name2 = self.state.add_transition("transition2") + assert transition_name1 != transition_name2 + + def test_update_dummy_transition(self): + transition_name = self.state.add_transition((self.event, Tutorial.END)) + assert self.event.i == 0 + + prop = self.event.get_properties_dict_copy() + prop["i"] = 2 + self.state.update_transition(transition_name, prop) + + assert len(self.state.get_transition_dict()) == 1 + assert self.state.get_transition_dict().has_key(transition_name) + assert self.state.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop + assert self.state.get_transition_dict()[transition_name][1] == Tutorial.END + + # Now update only the transition + self.state.update_transition(transition_name, new_state=Tutorial.INIT) + assert self.state.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop + assert self.state.get_transition_dict()[transition_name][1] == Tutorial.INIT + + def test_delete_dummy_transition(self): + transition_name = self.state.add_transition("transition1") + assert len(self.state.get_transition_dict()) == 1 + assert self.state.get_transition_dict().has_key(transition_name) + assert self.state.get_transition_dict()[transition_name] == "transition1" + + self.state.delete_transition(transition_name) + assert len(self.state.get_transition_dict()) == 0 + + def test_delete_all_dummy_transitions(self): + transition_name = self.state.add_transition("transition1") + assert len(self.state.get_transition_dict()) == 1 + assert self.state.get_transition_dict().has_key(transition_name) + assert self.state.get_transition_dict()[transition_name] == "transition1" + + self.state.delete_transitions() + assert len(self.state.get_transition_dict()) == 0 + + + + ######################### Limit cases ################################### + #### Action + + #### Transition + + ######################### Error cases ################################### + #### Action + def test_update_unknown_action(self): + try: + self.state.update_action("unknown_name", "action") + assert False + except LookupError: + pass + + def test_delete_unknown_action(self): + try: + self.state.delete_action("unknown_name") + assert False + except LookupError: + pass + + #### Transition + def test_add_existing_transition(self): + self.state.add_transition("transition") + try: + self.state.add_transition("transition") + assert False + except TransitionAlreadyExists: + pass + +class TutorialTest(unittest.TestCase): + """Test tutorial functionality""" + + def setUp(self): + self.tutorial = Tutorial("Tutorial Test") + self.action = MockAction() + self.event = MockEvent() + + def tearDown(self): + pass + + ######################### Basic interface cases ######################### + + #### Tutorial + def test_default_initial_value_in_tutorial(self): + assert len(self.tutorial.get_state_dict()) == 2 + assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END]) + assert len(self.tutorial.get_action_dict()) == 0 + assert len(self.tutorial.get_transition_dict()) == 1 + assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {} + assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END] + assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT] + assert self.tutorial.get_following_states_dict(Tutorial.END) == {} + + #### State + def test_add_default_state(self): + state_name = self.tutorial.add_state() + assert state_name + assert len(self.tutorial.get_state_dict()) == 3 + assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name]) + assert len(self.tutorial.get_action_dict()) == 0 + assert len(self.tutorial.get_transition_dict()) == 1 + + def test_add_state_with_action(self): + state_name = self.tutorial.add_state(action_list=["action1"]) + assert state_name + assert len(self.tutorial.get_state_dict()) == 3 + assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name]) + assert len(self.tutorial.get_action_dict()) == 1 + assert len(self.tutorial.get_transition_dict()) == 1 + + def test_add_state_with_transition(self): + state_name = self.tutorial.add_state(transition_list=[("event1",Tutorial.END)]) + assert state_name + assert len(self.tutorial.get_state_dict()) == 3 + assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name]) + assert len(self.tutorial.get_action_dict()) == 0 + assert len(self.tutorial.get_transition_dict()) == 2 + + def test_add_generate_unique_state_names(self): + state_name1 = self.tutorial.add_state() + state_name2 = self.tutorial.add_state() + assert state_name1 and state_name2 + assert state_name1 != state_name2 + + def test_delete_lone_state(self): + state_name1 = self.tutorial.add_state() + self.tutorial.delete_state(state_name1) + assert len(self.tutorial.get_state_dict()) == 2 + assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END]) + assert len(self.tutorial.get_action_dict()) == 0 + assert len(self.tutorial.get_transition_dict()) == 1 + assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {} + assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END] + assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT] + assert self.tutorial.get_following_states_dict(Tutorial.END) == {} + + def test_delete_linked_state(self): + state_name1 = self.tutorial.add_state() + self.tutorial.update_transition(Tutorial.INITIAL_TRANSITION_NAME, \ + None, state_name1) + transition_name1 = self.tutorial.add_transition(state_name1,("event1", Tutorial.END)) + self.tutorial.delete_state(state_name1) + assert len(self.tutorial.get_state_dict()) == 2 + assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END]) + assert len(self.tutorial.get_action_dict()) == 0 + assert len(self.tutorial.get_transition_dict()) == 1 + assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {} + assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END] + assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT] + assert self.tutorial.get_following_states_dict(Tutorial.END) == {} + + #### Action + def test_add_dummy_action(self): + state_name = self.tutorial.add_state() + action_name = self.tutorial.add_action(state_name,"action1") + assert action_name + assert len(self.tutorial.get_action_dict()) == 1 + assert self.tutorial.get_action_dict().has_key(action_name) + assert self.tutorial.get_action_dict()[action_name] == "action1" + + def test_add_generate_unique_action_names(self): + state_name = self.tutorial.add_state() + action_name1 = self.tutorial.add_action(state_name,"action1") + action_name2 = self.tutorial.add_action(state_name,"action2") + assert action_name1 and action_name2 + assert action_name1 != action_name2 + + def test_update_dummy_action(self): + state_name = self.tutorial.add_state() + action_name = self.tutorial.add_action(state_name,self.action) + + prop = self.action.get_properties_dict_copy() + prop["i"] = 2 + self.tutorial.update_action(action_name, prop) + + assert len(self.tutorial.get_action_dict()) == 1 + assert self.tutorial.get_action_dict().has_key(action_name) + assert self.tutorial.get_action_dict()[action_name].get_properties_dict_copy() == prop + + def test_delete_dummy_action(self): + state_name = self.tutorial.add_state() + action_name = self.tutorial.add_action(state_name,"action1") + assert len(self.tutorial.get_action_dict()) == 1 + assert self.tutorial.get_action_dict().has_key(action_name) + assert self.tutorial.get_action_dict()[action_name] == "action1" + + self.tutorial.delete_action(action_name) + assert len(self.tutorial.get_action_dict()) == 0 + + #### Transition + def test_add_dummy_transition(self): + state_name = self.tutorial.add_state() + transition_name = self.tutorial.add_transition(state_name,"transition1") + assert len(self.tutorial.get_transition_dict()) == 2 + assert self.tutorial.get_transition_dict().has_key(transition_name) + assert self.tutorial.get_transition_dict()[transition_name] == "transition1" + + def test_add_generate_unique_transition_names(self): + state_name = self.tutorial.add_state() + transition_name1 = self.tutorial.add_transition(state_name,"transition1") + transition_name2 = self.tutorial.add_transition(state_name,"transition2") + assert transition_name1 and transition_name2 + assert transition_name1 != transition_name2 + + def test_update_dummy_transition(self): + state_name = self.tutorial.add_state() + transition_name = self.tutorial.add_transition(state_name,(self.event, Tutorial.END)) + + prop = self.event.get_properties_dict_copy() + prop["i"] = 2 + self.tutorial.update_transition(transition_name, prop) + + assert len(self.tutorial.get_transition_dict()) == 2 + assert self.tutorial.get_transition_dict().has_key(transition_name) + assert self.tutorial.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop + assert self.tutorial.get_transition_dict()[transition_name][1] == Tutorial.END + + # Now update only the transition + self.tutorial.update_transition(transition_name, new_state=Tutorial.INIT) + assert self.tutorial.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop + assert self.tutorial.get_transition_dict()[transition_name][1] == Tutorial.INIT + + def test_delete_dummy_transition(self): + state_name = self.tutorial.add_state() + transition_name = self.tutorial.add_transition(state_name,"transition1") + assert len(self.tutorial.get_transition_dict()) == 2 + assert self.tutorial.get_transition_dict().has_key(transition_name) + assert self.tutorial.get_transition_dict()[transition_name] == "transition1" + + self.tutorial.delete_transition(transition_name) + assert len(self.tutorial.get_transition_dict()) == 1 + + + ######################### Limit cases ################################### + #### Tutorial + + #### State + + #### Action + + #### Transition + + ######################### Error cases ################################### + #### Tutorial + + #### State + + #### Action + def test_update_unknown_action(self): + lookup_error = None + try: + self.tutorial.update_action("unknown_name", "action") + except LookupError, e: + lookup_error = e + + assert lookup_error + + + def test_delete_unknown_action(self): + lookup_error = None + try: + self.tutorial.delete_action("unknown_name") + except LookupError, e: + lookup_error = e + + assert lookup_error + + #### Transition + def test_add_existing_transition(self): + self.tutorial.add_transition(Tutorial.INIT,("event","transition")) + transition_exists_error = None + try: + self.tutorial.add_transition(Tutorial.INIT,("event","transition")) + except TransitionAlreadyExists, e: + transition_exists_error = e + + assert transition_exists_error + + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..98738b8 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,49 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Simon Poirier +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +Here are some utility functions for easy tests maintenance +""" + +def catch_unimplemented(fnct): + """ + Decorator for globbing not implemented errors. + """ + def ret(self, *args, **kwargs): + try: + fnct(self, *args, **kwargs) + print "PREVIOUSLY UNIMPLEMENTED TEST PASSES. REMOVE THIS DECORATOR: %s (%s.%s)"%\ + (fnct.__name__, type(self).__module__, type(self).__name__) + except NotImplementedError: + pass + return ret + + +def skip(msg): + """ + Decorator for skipping pyunit tests. + + @type msg: str + @param msg: reason for skipping the test + """ + def ret(fnct): + print "SKIPPED TEST: %s (%s): %s"%(fnct.__name__, fnct.__module__, msg) + return ret + + +# vim:set ts=4 sts=4 sw=4 et: + diff --git a/tutorius/TProbe.py b/tutorius/TProbe.py index 6d7b6e2..f55547c 100644 --- a/tutorius/TProbe.py +++ b/tutorius/TProbe.py @@ -8,12 +8,12 @@ import dbus import dbus.service import cPickle as pickle -import sugar.tutorius.addon as addon -from sugar.tutorius.services import ObjectStore -from sugar.tutorius.properties import TPropContainer +from . import addon +from .services import ObjectStore +from .properties import TPropContainer -from sugar.tutorius.dbustools import remote_call, save_args +from .dbustools import remote_call, save_args import copy """ @@ -195,7 +195,11 @@ class TProbe(dbus.service.Object): # The actual method we will call on the probe to send events def notify(self, event): LOGGER.debug("TProbe :: notify event %s", str(event)) - self.eventOccured(pickle.dumps(event)) + #Check that this event is even allowed + if event in self._subscribedEvents.values(): + self.eventOccured(pickle.dumps(event)) + else: + raise RuntimeWarning("Attempted to raise an unregistered event") # Return a unique name for this action def _generate_action_reference(self, action): @@ -388,8 +392,6 @@ class ProbeProxy: @return None """ LOGGER.debug("ProbeProxy :: Unregister adress %s issued", str(address)) - if not block: - raise RuntimeError("This function does not allow non-blocking mode yet") if address in self._subscribedEvents.keys(): remote_call(self._probe.unsubscribe, (address,), return_cb=save_args(self.__clear_event, address), @@ -402,8 +404,8 @@ class ProbeProxy: Detach the ProbeProxy from it's TProbe. All installed actions and subscribed events should be removed. """ - for action in self._actions.keys(): - self.uninstall(action, block) + for action_addr in self._actions.keys(): + self.uninstall(action_addr, block) for address in self._subscribedEvents.keys(): self.unsubscribe(address, block) @@ -416,7 +418,13 @@ class ProbeManager(object): For now, it only handles one at a time, though. Actually it doesn't do much at all. But it keeps your encapsulation happy """ - def __init__(self): + def __init__(self, proxy_class=ProbeProxy): + """Constructor + @param proxy_class Class to use for creating Proxies to activities. + The class should support the same interface as ProbeProxy. Exists + to make this class unit-testable by replacing the Proxy with a mock + """ + self._ProxyClass = proxy_class self._probes = {} self._current_activity = None @@ -433,7 +441,7 @@ class ProbeManager(object): if activity_id in self._probes: raise RuntimeWarning("Activity already attached") - self._probes[activity_id] = ProbeProxy(activity_id) + self._probes[activity_id] = self._ProxyClass(activity_id) #TODO what do we do with this? Raise something? if self._probes[activity_id].isAlive(): print "Alive!" @@ -444,6 +452,8 @@ class ProbeManager(object): if activity_id in self._probes: probe = self._probes.pop(activity_id) probe.detach() + if self._current_activity == activity_id: + self._current_activity = None def install(self, action, block=False): """ diff --git a/tutorius/actions.py b/tutorius/actions.py index 08f55cd..bb15459 100644 --- a/tutorius/actions.py +++ b/tutorius/actions.py @@ -20,11 +20,12 @@ import gtk from gettext import gettext as _ -from sugar.tutorius import addon -from sugar.tutorius.services import ObjectStore -from sugar.tutorius.properties import * from sugar.graphics import icon +from . import addon +from .services import ObjectStore +from .properties import * + class DragWrapper(object): """Wrapper to allow gtk widgets to be dragged around""" def __init__(self, widget, position, draggable=False): diff --git a/tutorius/addon.py b/tutorius/addon.py index 15612c8..7ac68f7 100644 --- a/tutorius/addon.py +++ b/tutorius/addon.py @@ -38,6 +38,9 @@ import logging PREFIX = __name__+"s" PATH = re.sub("addon\\.py[c]$", "", __file__)+"addons" +TYPE_ACTION = 'action' +TYPE_EVENT = 'event' + _cache = None def _reload_addons(): @@ -47,9 +50,11 @@ def _reload_addons(): mod = __import__(PREFIX+'.'+re.sub("\\.py$", "", addon), {}, {}, [""]) if hasattr(mod, "__action__"): _cache[mod.__action__['name']] = mod.__action__ + mod.__action__['type'] = TYPE_ACTION continue if hasattr(mod, "__event__"): _cache[mod.__event__['name']] = mod.__event__ + mod.__event__['type'] = TYPE_EVENT def create(name, *args, **kwargs): global _cache @@ -78,4 +83,13 @@ def get_addon_meta(name): _reload_addons() return _cache[name] +def get_name_from_type(typ): + global _cache + if not _cache: + _reload_addons() + for addon in _cache.keys(): + if typ == _cache[addon]['class']: + return addon + return None + # vim:set ts=4 sts=4 sw=4 et: diff --git a/tutorius/apilib/__init__.py b/tutorius/apilib/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tutorius/apilib/__init__.py diff --git a/tutorius/apilib/__init__.pyc b/tutorius/apilib/__init__.pyc new file mode 100644 index 0000000..bd4346b --- /dev/null +++ b/tutorius/apilib/__init__.pyc Binary files differ diff --git a/tutorius/apilib/httplib2/.svn/all-wcprops b/tutorius/apilib/httplib2/.svn/all-wcprops new file mode 100644 index 0000000..601feb8 --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/all-wcprops @@ -0,0 +1,29 @@ +K 25 +svn:wc:ra_dav:version-url +V 30 +/svn/!svn/ver/2/trunk/httplib2 +END +iri2uri.pyc +K 25 +svn:wc:ra_dav:version-url +V 42 +/svn/!svn/ver/2/trunk/httplib2/iri2uri.pyc +END +__init__.py +K 25 +svn:wc:ra_dav:version-url +V 42 +/svn/!svn/ver/2/trunk/httplib2/__init__.py +END +__init__.pyc +K 25 +svn:wc:ra_dav:version-url +V 43 +/svn/!svn/ver/2/trunk/httplib2/__init__.pyc +END +iri2uri.py +K 25 +svn:wc:ra_dav:version-url +V 41 +/svn/!svn/ver/2/trunk/httplib2/iri2uri.py +END diff --git a/tutorius/apilib/httplib2/.svn/entries b/tutorius/apilib/httplib2/.svn/entries new file mode 100644 index 0000000..1a3c5d2 --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/entries @@ -0,0 +1,66 @@ +8 + +dir +2 +https://python-rest-client.googlecode.com/svn/trunk/httplib2 +https://python-rest-client.googlecode.com/svn + + + +2008-05-14T17:00:19.245332Z +2 +bosteen + + +svn:special svn:externals svn:needs-lock + +iri2uri.pyc +file + + + + +2008-05-14T14:48:03.000000Z +6f9a0833a6dc59c42b7aec0dfdf39dd0 +2008-05-14T17:00:19.245332Z +2 +bosteen +has-props + +__init__.py +file + + + + +2007-10-23T15:25:46.000000Z +00c607566b698248d5a5c40508843cd7 +2008-05-14T17:00:19.245332Z +2 +bosteen + +__init__.pyc +file + + + + +2008-05-14T14:48:03.000000Z +bdf8607edad61c67d890de558db8006c +2008-05-14T17:00:19.245332Z +2 +bosteen +has-props + +iri2uri.py +file + + + + +2007-09-04T04:02:06.000000Z +c0f9c5cb229a22e21575322b4ba77741 +2008-05-14T17:00:19.245332Z +2 +bosteen + diff --git a/tutorius/apilib/httplib2/.svn/format b/tutorius/apilib/httplib2/.svn/format new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/format @@ -0,0 +1 @@ +8 diff --git a/tutorius/apilib/httplib2/.svn/prop-base/__init__.pyc.svn-base b/tutorius/apilib/httplib2/.svn/prop-base/__init__.pyc.svn-base new file mode 100644 index 0000000..5e9587e --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/prop-base/__init__.pyc.svn-base @@ -0,0 +1,5 @@ +K 13 +svn:mime-type +V 24 +application/octet-stream +END diff --git a/tutorius/apilib/httplib2/.svn/prop-base/iri2uri.pyc.svn-base b/tutorius/apilib/httplib2/.svn/prop-base/iri2uri.pyc.svn-base new file mode 100644 index 0000000..5e9587e --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/prop-base/iri2uri.pyc.svn-base @@ -0,0 +1,5 @@ +K 13 +svn:mime-type +V 24 +application/octet-stream +END diff --git a/tutorius/apilib/httplib2/.svn/text-base/__init__.py.svn-base b/tutorius/apilib/httplib2/.svn/text-base/__init__.py.svn-base new file mode 100644 index 0000000..982bf8a --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/text-base/__init__.py.svn-base @@ -0,0 +1,1123 @@ +from __future__ import generators +""" +httplib2 + +A caching http interface that supports ETags and gzip +to conserve bandwidth. + +Requires Python 2.3 or later + +Changelog: +2007-08-18, Rick: Modified so it's able to use a socks proxy if needed. + +""" + +__author__ = "Joe Gregorio (joe@bitworking.org)" +__copyright__ = "Copyright 2006, Joe Gregorio" +__contributors__ = ["Thomas Broyer (t.broyer@ltgt.net)", + "James Antill", + "Xavier Verges Farrero", + "Jonathan Feinberg", + "Blair Zajac", + "Sam Ruby", + "Louis Nyffenegger"] +__license__ = "MIT" +__version__ = "$Rev: 259 $" + +import re +import sys +import md5 +import email +import email.Utils +import email.Message +import StringIO +import gzip +import zlib +import httplib +import urlparse +import base64 +import os +import copy +import calendar +import time +import random +import sha +import hmac +from gettext import gettext as _ +import socket + +try: + import socks +except ImportError: + socks = None + +if sys.version_info >= (2,3): + from iri2uri import iri2uri +else: + def iri2uri(uri): + return uri + +__all__ = ['Http', 'Response', 'ProxyInfo', 'HttpLib2Error', + 'RedirectMissingLocation', 'RedirectLimit', 'FailedToDecompressContent', + 'UnimplementedDigestAuthOptionError', 'UnimplementedHmacDigestAuthOptionError', + 'debuglevel'] + + +# The httplib debug level, set to a non-zero value to get debug output +debuglevel = 0 + +# Python 2.3 support +if sys.version_info < (2,4): + def sorted(seq): + seq.sort() + return seq + +# Python 2.3 support +def HTTPResponse__getheaders(self): + """Return list of (header, value) tuples.""" + if self.msg is None: + raise httplib.ResponseNotReady() + return self.msg.items() + +if not hasattr(httplib.HTTPResponse, 'getheaders'): + httplib.HTTPResponse.getheaders = HTTPResponse__getheaders + +# All exceptions raised here derive from HttpLib2Error +class HttpLib2Error(Exception): pass + +# Some exceptions can be caught and optionally +# be turned back into responses. +class HttpLib2ErrorWithResponse(HttpLib2Error): + def __init__(self, desc, response, content): + self.response = response + self.content = content + HttpLib2Error.__init__(self, desc) + +class RedirectMissingLocation(HttpLib2ErrorWithResponse): pass +class RedirectLimit(HttpLib2ErrorWithResponse): pass +class FailedToDecompressContent(HttpLib2ErrorWithResponse): pass +class UnimplementedDigestAuthOptionError(HttpLib2ErrorWithResponse): pass +class UnimplementedHmacDigestAuthOptionError(HttpLib2ErrorWithResponse): pass + +class RelativeURIError(HttpLib2Error): pass +class ServerNotFoundError(HttpLib2Error): pass + +# Open Items: +# ----------- +# Proxy support + +# Are we removing the cached content too soon on PUT (only delete on 200 Maybe?) + +# Pluggable cache storage (supports storing the cache in +# flat files by default. We need a plug-in architecture +# that can support Berkeley DB and Squid) + +# == Known Issues == +# Does not handle a resource that uses conneg and Last-Modified but no ETag as a cache validator. +# Does not handle Cache-Control: max-stale +# Does not use Age: headers when calculating cache freshness. + + +# The number of redirections to follow before giving up. +# Note that only GET redirects are automatically followed. +# Will also honor 301 requests by saving that info and never +# requesting that URI again. +DEFAULT_MAX_REDIRECTS = 5 + +# Which headers are hop-by-hop headers by default +HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade'] + +def _get_end2end_headers(response): + hopbyhop = list(HOP_BY_HOP) + hopbyhop.extend([x.strip() for x in response.get('connection', '').split(',')]) + return [header for header in response.keys() if header not in hopbyhop] + +URI = re.compile(r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?") + +def parse_uri(uri): + """Parses a URI using the regex given in Appendix B of RFC 3986. + + (scheme, authority, path, query, fragment) = parse_uri(uri) + """ + groups = URI.match(uri).groups() + return (groups[1], groups[3], groups[4], groups[6], groups[8]) + +def urlnorm(uri): + (scheme, authority, path, query, fragment) = parse_uri(uri) + if not scheme or not authority: + raise RelativeURIError("Only absolute URIs are allowed. uri = %s" % uri) + authority = authority.lower() + scheme = scheme.lower() + if not path: + path = "/" + # Could do syntax based normalization of the URI before + # computing the digest. See Section 6.2.2 of Std 66. + request_uri = query and "?".join([path, query]) or path + scheme = scheme.lower() + defrag_uri = scheme + "://" + authority + request_uri + return scheme, authority, request_uri, defrag_uri + + +# Cache filename construction (original borrowed from Venus http://intertwingly.net/code/venus/) +re_url_scheme = re.compile(r'^\w+://') +re_slash = re.compile(r'[?/:|]+') + +def safename(filename): + """Return a filename suitable for the cache. + + Strips dangerous and common characters to create a filename we + can use to store the cache in. + """ + + try: + if re_url_scheme.match(filename): + if isinstance(filename,str): + filename = filename.decode('utf-8') + filename = filename.encode('idna') + else: + filename = filename.encode('idna') + except UnicodeError: + pass + if isinstance(filename,unicode): + filename=filename.encode('utf-8') + filemd5 = md5.new(filename).hexdigest() + filename = re_url_scheme.sub("", filename) + filename = re_slash.sub(",", filename) + + # limit length of filename + if len(filename)>200: + filename=filename[:200] + return ",".join((filename, filemd5)) + +NORMALIZE_SPACE = re.compile(r'(?:\r\n)?[ \t]+') +def _normalize_headers(headers): + return dict([ (key.lower(), NORMALIZE_SPACE.sub(value, ' ').strip()) for (key, value) in headers.iteritems()]) + +def _parse_cache_control(headers): + retval = {} + if headers.has_key('cache-control'): + parts = headers['cache-control'].split(',') + parts_with_args = [tuple([x.strip() for x in part.split("=")]) for part in parts if -1 != part.find("=")] + parts_wo_args = [(name.strip(), 1) for name in parts if -1 == name.find("=")] + retval = dict(parts_with_args + parts_wo_args) + return retval + +# Whether to use a strict mode to parse WWW-Authenticate headers +# Might lead to bad results in case of ill-formed header value, +# so disabled by default, falling back to relaxed parsing. +# Set to true to turn on, usefull for testing servers. +USE_WWW_AUTH_STRICT_PARSING = 0 + +# In regex below: +# [^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+ matches a "token" as defined by HTTP +# "(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?" matches a "quoted-string" as defined by HTTP, when LWS have already been replaced by a single space +# Actually, as an auth-param value can be either a token or a quoted-string, they are combined in a single pattern which matches both: +# \"?((?<=\")(?:[^\0-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?@,;:\\\"/[\]?={} \t]+(?!\"))\"? +WWW_AUTH_STRICT = re.compile(r"^(?:\s*(?:,\s*)?([^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+)\s*=\s*\"?((?<=\")(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?@,;:\\\"/[\]?={} \t]+(?!\"))\"?)(.*)$") +WWW_AUTH_RELAXED = re.compile(r"^(?:\s*(?:,\s*)?([^ \t\r\n=]+)\s*=\s*\"?((?<=\")(?:[^\\\"]|\\.)*?(?=\")|(? current_age: + retval = "FRESH" + return retval + +def _decompressContent(response, new_content): + content = new_content + try: + encoding = response.get('content-encoding', None) + if encoding in ['gzip', 'deflate']: + if encoding == 'gzip': + content = gzip.GzipFile(fileobj=StringIO.StringIO(new_content)).read() + if encoding == 'deflate': + content = zlib.decompress(content) + response['content-length'] = str(len(content)) + del response['content-encoding'] + except IOError: + content = "" + raise FailedToDecompressContent(_("Content purported to be compressed with %s but failed to decompress.") % response.get('content-encoding'), response, content) + return content + +def _updateCache(request_headers, response_headers, content, cache, cachekey): + if cachekey: + cc = _parse_cache_control(request_headers) + cc_response = _parse_cache_control(response_headers) + if cc.has_key('no-store') or cc_response.has_key('no-store'): + cache.delete(cachekey) + else: + info = email.Message.Message() + for key, value in response_headers.iteritems(): + if key not in ['status','content-encoding','transfer-encoding']: + info[key] = value + + status = response_headers.status + if status == 304: + status = 200 + + status_header = 'status: %d\r\n' % response_headers.status + + header_str = info.as_string() + + header_str = re.sub("\r(?!\n)|(? 0: + service = "cl" + # No point in guessing Base or Spreadsheet + #elif request_uri.find("spreadsheets") > 0: + # service = "wise" + + auth = dict(Email=credentials[0], Passwd=credentials[1], service=service, source=headers['user-agent']) + resp, content = self.http.request("https://www.google.com/accounts/ClientLogin", method="POST", body=urlencode(auth), headers={'Content-Type': 'application/x-www-form-urlencoded'}) + lines = content.split('\n') + d = dict([tuple(line.split("=", 1)) for line in lines if line]) + if resp.status == 403: + self.Auth = "" + else: + self.Auth = d['Auth'] + + def request(self, method, request_uri, headers, content): + """Modify the request headers to add the appropriate + Authorization header.""" + headers['authorization'] = 'GoogleLogin Auth=' + self.Auth + + +AUTH_SCHEME_CLASSES = { + "basic": BasicAuthentication, + "wsse": WsseAuthentication, + "digest": DigestAuthentication, + "hmacdigest": HmacDigestAuthentication, + "googlelogin": GoogleLoginAuthentication +} + +AUTH_SCHEME_ORDER = ["hmacdigest", "googlelogin", "digest", "wsse", "basic"] + +def _md5(s): + return + +class FileCache(object): + """Uses a local directory as a store for cached files. + Not really safe to use if multiple threads or processes are going to + be running on the same cache. + """ + def __init__(self, cache, safe=safename): # use safe=lambda x: md5.new(x).hexdigest() for the old behavior + self.cache = cache + self.safe = safe + if not os.path.exists(cache): + os.makedirs(self.cache) + + def get(self, key): + retval = None + cacheFullPath = os.path.join(self.cache, self.safe(key)) + try: + f = file(cacheFullPath, "r") + retval = f.read() + f.close() + except IOError: + pass + return retval + + def set(self, key, value): + cacheFullPath = os.path.join(self.cache, self.safe(key)) + f = file(cacheFullPath, "w") + f.write(value) + f.close() + + def delete(self, key): + cacheFullPath = os.path.join(self.cache, self.safe(key)) + if os.path.exists(cacheFullPath): + os.remove(cacheFullPath) + +class Credentials(object): + def __init__(self): + self.credentials = [] + + def add(self, name, password, domain=""): + self.credentials.append((domain.lower(), name, password)) + + def clear(self): + self.credentials = [] + + def iter(self, domain): + for (cdomain, name, password) in self.credentials: + if cdomain == "" or domain == cdomain: + yield (name, password) + +class KeyCerts(Credentials): + """Identical to Credentials except that + name/password are mapped to key/cert.""" + pass + + +class ProxyInfo(object): + """Collect information required to use a proxy.""" + def __init__(self, proxy_type, proxy_host, proxy_port, proxy_rdns=None, proxy_user=None, proxy_pass=None): + """The parameter proxy_type must be set to one of socks.PROXY_TYPE_XXX + constants. For example: + +p = ProxyInfo(proxy_type=socks.PROXY_TYPE_HTTP, proxy_host='localhost', proxy_port=8000) + """ + self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, self.proxy_user, self.proxy_pass = proxy_type, proxy_host, proxy_port, proxy_rdns, proxy_user, proxy_pass + + def astuple(self): + return (self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, + self.proxy_user, self.proxy_pass) + + def isgood(self): + return socks and (self.proxy_host != None) and (self.proxy_port != None) + + +class HTTPConnectionWithTimeout(httplib.HTTPConnection): + """HTTPConnection subclass that supports timeouts""" + + def __init__(self, host, port=None, strict=None, timeout=None, proxy_info=None): + httplib.HTTPConnection.__init__(self, host, port, strict) + self.timeout = timeout + self.proxy_info = proxy_info + + def connect(self): + """Connect to the host and port specified in __init__.""" + # Mostly verbatim from httplib.py. + msg = "getaddrinfo returns an empty list" + for res in socket.getaddrinfo(self.host, self.port, 0, + socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + try: + if self.proxy_info and self.proxy_info.isgood(): + self.sock = socks.socksocket(af, socktype, proto) + self.sock.setproxy(*self.proxy_info.astuple()) + else: + self.sock = socket.socket(af, socktype, proto) + # Different from httplib: support timeouts. + if self.timeout is not None: + self.sock.settimeout(self.timeout) + # End of difference from httplib. + if self.debuglevel > 0: + print "connect: (%s, %s)" % (self.host, self.port) + self.sock.connect(sa) + except socket.error, msg: + if self.debuglevel > 0: + print 'connect fail:', (self.host, self.port) + if self.sock: + self.sock.close() + self.sock = None + continue + break + if not self.sock: + raise socket.error, msg + +class HTTPSConnectionWithTimeout(httplib.HTTPSConnection): + "This class allows communication via SSL." + + def __init__(self, host, port=None, key_file=None, cert_file=None, + strict=None, timeout=None, proxy_info=None): + self.timeout = timeout + self.proxy_info = proxy_info + httplib.HTTPSConnection.__init__(self, host, port=port, key_file=key_file, + cert_file=cert_file, strict=strict) + + def connect(self): + "Connect to a host on a given (SSL) port." + + if self.proxy_info and self.proxy_info.isgood(): + self.sock.setproxy(*self.proxy_info.astuple()) + sock.setproxy(*self.proxy_info.astuple()) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if self.timeout is not None: + sock.settimeout(self.timeout) + sock.connect((self.host, self.port)) + ssl = socket.ssl(sock, self.key_file, self.cert_file) + self.sock = httplib.FakeSocket(sock, ssl) + + + +class Http(object): + """An HTTP client that handles: +- all methods +- caching +- ETags +- compression, +- HTTPS +- Basic +- Digest +- WSSE + +and more. + """ + def __init__(self, cache=None, timeout=None, proxy_info=None): + """The value of proxy_info is a ProxyInfo instance. + +If 'cache' is a string then it is used as a directory name +for a disk cache. Otherwise it must be an object that supports +the same interface as FileCache.""" + self.proxy_info = proxy_info + # Map domain name to an httplib connection + self.connections = {} + # The location of the cache, for now a directory + # where cached responses are held. + if cache and isinstance(cache, str): + self.cache = FileCache(cache) + else: + self.cache = cache + + # Name/password + self.credentials = Credentials() + + # Key/cert + self.certificates = KeyCerts() + + # authorization objects + self.authorizations = [] + + # If set to False then no redirects are followed, even safe ones. + self.follow_redirects = True + + # If 'follow_redirects' is True, and this is set to True then + # all redirecs are followed, including unsafe ones. + self.follow_all_redirects = False + + self.ignore_etag = False + + self.force_exception_to_status_code = False + + self.timeout = timeout + + def _auth_from_challenge(self, host, request_uri, headers, response, content): + """A generator that creates Authorization objects + that can be applied to requests. + """ + challenges = _parse_www_authenticate(response, 'www-authenticate') + for cred in self.credentials.iter(host): + for scheme in AUTH_SCHEME_ORDER: + if challenges.has_key(scheme): + yield AUTH_SCHEME_CLASSES[scheme](cred, host, request_uri, headers, response, content, self) + + def add_credentials(self, name, password, domain=""): + """Add a name and password that will be used + any time a request requires authentication.""" + self.credentials.add(name, password, domain) + + def add_certificate(self, key, cert, domain): + """Add a key and cert that will be used + any time a request requires authentication.""" + self.certificates.add(key, cert, domain) + + def clear_credentials(self): + """Remove all the names and passwords + that are used for authentication""" + self.credentials.clear() + self.authorizations = [] + + def _conn_request(self, conn, request_uri, method, body, headers): + for i in range(2): + try: + conn.request(method, request_uri, body, headers) + response = conn.getresponse() + except socket.gaierror: + conn.close() + raise ServerNotFoundError("Unable to find the server at %s" % conn.host) + except httplib.HTTPException, e: + if i == 0: + conn.close() + conn.connect() + continue + else: + raise + else: + content = response.read() + response = Response(response) + if method != "HEAD": + content = _decompressContent(response, content) + + break; + return (response, content) + + + def _request(self, conn, host, absolute_uri, request_uri, method, body, headers, redirections, cachekey): + """Do the actual request using the connection object + and also follow one level of redirects if necessary""" + + auths = [(auth.depth(request_uri), auth) for auth in self.authorizations if auth.inscope(host, request_uri)] + auth = auths and sorted(auths)[0][1] or None + if auth: + auth.request(method, request_uri, headers, body) + + (response, content) = self._conn_request(conn, request_uri, method, body, headers) + + if auth: + if auth.response(response, body): + auth.request(method, request_uri, headers, body) + (response, content) = self._conn_request(conn, request_uri, method, body, headers ) + response._stale_digest = 1 + + if response.status == 401: + for authorization in self._auth_from_challenge(host, request_uri, headers, response, content): + authorization.request(method, request_uri, headers, body) + (response, content) = self._conn_request(conn, request_uri, method, body, headers, ) + if response.status != 401: + self.authorizations.append(authorization) + authorization.response(response, body) + break + + if (self.follow_all_redirects or (method in ["GET", "HEAD"]) or response.status == 303): + if self.follow_redirects and response.status in [300, 301, 302, 303, 307]: + # Pick out the location header and basically start from the beginning + # remembering first to strip the ETag header and decrement our 'depth' + if redirections: + if not response.has_key('location') and response.status != 300: + raise RedirectMissingLocation( _("Redirected but the response is missing a Location: header."), response, content) + # Fix-up relative redirects (which violate an RFC 2616 MUST) + if response.has_key('location'): + location = response['location'] + (scheme, authority, path, query, fragment) = parse_uri(location) + if authority == None: + response['location'] = urlparse.urljoin(absolute_uri, location) + if response.status == 301 and method in ["GET", "HEAD"]: + response['-x-permanent-redirect-url'] = response['location'] + if not response.has_key('content-location'): + response['content-location'] = absolute_uri + _updateCache(headers, response, content, self.cache, cachekey) + if headers.has_key('if-none-match'): + del headers['if-none-match'] + if headers.has_key('if-modified-since'): + del headers['if-modified-since'] + if response.has_key('location'): + location = response['location'] + old_response = copy.deepcopy(response) + if not old_response.has_key('content-location'): + old_response['content-location'] = absolute_uri + redirect_method = ((response.status == 303) and (method not in ["GET", "HEAD"])) and "GET" or method + (response, content) = self.request(location, redirect_method, body=body, headers = headers, redirections = redirections - 1) + response.previous = old_response + else: + raise RedirectLimit( _("Redirected more times than rediection_limit allows."), response, content) + elif response.status in [200, 203] and method == "GET": + # Don't cache 206's since we aren't going to handle byte range requests + if not response.has_key('content-location'): + response['content-location'] = absolute_uri + _updateCache(headers, response, content, self.cache, cachekey) + + return (response, content) + + +# Need to catch and rebrand some exceptions +# Then need to optionally turn all exceptions into status codes +# including all socket.* and httplib.* exceptions. + + + def request(self, uri, method="GET", body=None, headers=None, redirections=DEFAULT_MAX_REDIRECTS, connection_type=None): + """ Performs a single HTTP request. +The 'uri' is the URI of the HTTP resource and can begin +with either 'http' or 'https'. The value of 'uri' must be an absolute URI. + +The 'method' is the HTTP method to perform, such as GET, POST, DELETE, etc. +There is no restriction on the methods allowed. + +The 'body' is the entity body to be sent with the request. It is a string +object. + +Any extra headers that are to be sent with the request should be provided in the +'headers' dictionary. + +The maximum number of redirect to follow before raising an +exception is 'redirections. The default is 5. + +The return value is a tuple of (response, content), the first +being and instance of the 'Response' class, the second being +a string that contains the response entity body. + """ + try: + if headers is None: + headers = {} + else: + headers = _normalize_headers(headers) + + if not headers.has_key('user-agent'): + headers['user-agent'] = "Python-httplib2/%s" % __version__ + + uri = iri2uri(uri) + + (scheme, authority, request_uri, defrag_uri) = urlnorm(uri) + + conn_key = scheme+":"+authority + if conn_key in self.connections: + conn = self.connections[conn_key] + else: + if not connection_type: + connection_type = (scheme == 'https') and HTTPSConnectionWithTimeout or HTTPConnectionWithTimeout + certs = list(self.certificates.iter(authority)) + if scheme == 'https' and certs: + conn = self.connections[conn_key] = connection_type(authority, key_file=certs[0][0], + cert_file=certs[0][1], timeout=self.timeout, proxy_info=self.proxy_info) + else: + conn = self.connections[conn_key] = connection_type(authority, timeout=self.timeout, proxy_info=self.proxy_info) + conn.set_debuglevel(debuglevel) + + if method in ["GET", "HEAD"] and 'range' not in headers: + headers['accept-encoding'] = 'compress, gzip' + + info = email.Message.Message() + cached_value = None + if self.cache: + cachekey = defrag_uri + cached_value = self.cache.get(cachekey) + if cached_value: + info = email.message_from_string(cached_value) + try: + content = cached_value.split('\r\n\r\n', 1)[1] + except IndexError: + self.cache.delete(cachekey) + cachekey = None + cached_value = None + else: + cachekey = None + + if method in ["PUT"] and self.cache and info.has_key('etag') and not self.ignore_etag and 'if-match' not in headers: + # http://www.w3.org/1999/04/Editing/ + headers['if-match'] = info['etag'] + + if method not in ["GET", "HEAD"] and self.cache and cachekey: + # RFC 2616 Section 13.10 + self.cache.delete(cachekey) + + if cached_value and method in ["GET", "HEAD"] and self.cache and 'range' not in headers: + if info.has_key('-x-permanent-redirect-url'): + # Should cached permanent redirects be counted in our redirection count? For now, yes. + (response, new_content) = self.request(info['-x-permanent-redirect-url'], "GET", headers = headers, redirections = redirections - 1) + response.previous = Response(info) + response.previous.fromcache = True + else: + # Determine our course of action: + # Is the cached entry fresh or stale? + # Has the client requested a non-cached response? + # + # There seems to be three possible answers: + # 1. [FRESH] Return the cache entry w/o doing a GET + # 2. [STALE] Do the GET (but add in cache validators if available) + # 3. [TRANSPARENT] Do a GET w/o any cache validators (Cache-Control: no-cache) on the request + entry_disposition = _entry_disposition(info, headers) + + if entry_disposition == "FRESH": + if not cached_value: + info['status'] = '504' + content = "" + response = Response(info) + if cached_value: + response.fromcache = True + return (response, content) + + if entry_disposition == "STALE": + if info.has_key('etag') and not self.ignore_etag and not 'if-none-match' in headers: + headers['if-none-match'] = info['etag'] + if info.has_key('last-modified') and not 'last-modified' in headers: + headers['if-modified-since'] = info['last-modified'] + elif entry_disposition == "TRANSPARENT": + pass + + (response, new_content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey) + + if response.status == 304 and method == "GET": + # Rewrite the cache entry with the new end-to-end headers + # Take all headers that are in response + # and overwrite their values in info. + # unless they are hop-by-hop, or are listed in the connection header. + + for key in _get_end2end_headers(response): + info[key] = response[key] + merged_response = Response(info) + if hasattr(response, "_stale_digest"): + merged_response._stale_digest = response._stale_digest + _updateCache(headers, merged_response, content, self.cache, cachekey) + response = merged_response + response.status = 200 + response.fromcache = True + + elif response.status == 200: + content = new_content + else: + self.cache.delete(cachekey) + content = new_content + else: + (response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey) + except Exception, e: + if self.force_exception_to_status_code: + if isinstance(e, HttpLib2ErrorWithResponse): + response = e.response + content = e.content + response.status = 500 + response.reason = str(e) + elif isinstance(e, socket.timeout): + content = "Request Timeout" + response = Response( { + "content-type": "text/plain", + "status": "408", + "content-length": len(content) + }) + response.reason = "Request Timeout" + else: + content = str(e) + response = Response( { + "content-type": "text/plain", + "status": "400", + "content-length": len(content) + }) + response.reason = "Bad Request" + else: + raise + + + return (response, content) + + + +class Response(dict): + """An object more like email.Message than httplib.HTTPResponse.""" + + """Is this response from our local cache""" + fromcache = False + + """HTTP protocol version used by server. 10 for HTTP/1.0, 11 for HTTP/1.1. """ + version = 11 + + "Status code returned by server. " + status = 200 + + """Reason phrase returned by server.""" + reason = "Ok" + + previous = None + + def __init__(self, info): + # info is either an email.Message or + # an httplib.HTTPResponse object. + if isinstance(info, httplib.HTTPResponse): + for key, value in info.getheaders(): + self[key] = value + self.status = info.status + self['status'] = str(self.status) + self.reason = info.reason + self.version = info.version + elif isinstance(info, email.Message.Message): + for key, value in info.items(): + self[key] = value + self.status = int(self['status']) + else: + for key, value in info.iteritems(): + self[key] = value + self.status = int(self.get('status', self.status)) + + + def __getattr__(self, name): + if name == 'dict': + return self + else: + raise AttributeError, name diff --git a/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base b/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base new file mode 100644 index 0000000..f092204 --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base Binary files differ diff --git a/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base new file mode 100644 index 0000000..70667ed --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base @@ -0,0 +1,110 @@ +""" +iri2uri + +Converts an IRI to a URI. + +""" +__author__ = "Joe Gregorio (joe@bitworking.org)" +__copyright__ = "Copyright 2006, Joe Gregorio" +__contributors__ = [] +__version__ = "1.0.0" +__license__ = "MIT" +__history__ = """ +""" + +import urlparse + + +# Convert an IRI to a URI following the rules in RFC 3987 +# +# The characters we need to enocde and escape are defined in the spec: +# +# iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD +# ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF +# / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD +# / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD +# / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD +# / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD +# / %xD0000-DFFFD / %xE1000-EFFFD + +escape_range = [ + (0xA0, 0xD7FF ), + (0xE000, 0xF8FF ), + (0xF900, 0xFDCF ), + (0xFDF0, 0xFFEF), + (0x10000, 0x1FFFD ), + (0x20000, 0x2FFFD ), + (0x30000, 0x3FFFD), + (0x40000, 0x4FFFD ), + (0x50000, 0x5FFFD ), + (0x60000, 0x6FFFD), + (0x70000, 0x7FFFD ), + (0x80000, 0x8FFFD ), + (0x90000, 0x9FFFD), + (0xA0000, 0xAFFFD ), + (0xB0000, 0xBFFFD ), + (0xC0000, 0xCFFFD), + (0xD0000, 0xDFFFD ), + (0xE1000, 0xEFFFD), + (0xF0000, 0xFFFFD ), + (0x100000, 0x10FFFD) +] + +def encode(c): + retval = c + i = ord(c) + for low, high in escape_range: + if i < low: + break + if i >= low and i <= high: + retval = "".join(["%%%2X" % ord(o) for o in c.encode('utf-8')]) + break + return retval + + +def iri2uri(uri): + """Convert an IRI to a URI. Note that IRIs must be + passed in a unicode strings. That is, do not utf-8 encode + the IRI before passing it into the function.""" + if isinstance(uri ,unicode): + (scheme, authority, path, query, fragment) = urlparse.urlsplit(uri) + authority = authority.encode('idna') + # For each character in 'ucschar' or 'iprivate' + # 1. encode as utf-8 + # 2. then %-encode each octet of that utf-8 + uri = urlparse.urlunsplit((scheme, authority, path, query, fragment)) + uri = "".join([encode(c) for c in uri]) + return uri + +if __name__ == "__main__": + import unittest + + class Test(unittest.TestCase): + + def test_uris(self): + """Test that URIs are invariant under the transformation.""" + invariant = [ + u"ftp://ftp.is.co.za/rfc/rfc1808.txt", + u"http://www.ietf.org/rfc/rfc2396.txt", + u"ldap://[2001:db8::7]/c=GB?objectClass?one", + u"mailto:John.Doe@example.com", + u"news:comp.infosystems.www.servers.unix", + u"tel:+1-816-555-1212", + u"telnet://192.0.2.16:80/", + u"urn:oasis:names:specification:docbook:dtd:xml:4.1.2" ] + for uri in invariant: + self.assertEqual(uri, iri2uri(uri)) + + def test_iri(self): + """ Test that the right type of escaping is done for each part of the URI.""" + self.assertEqual("http://xn--o3h.com/%E2%98%84", iri2uri(u"http://\N{COMET}.com/\N{COMET}")) + self.assertEqual("http://bitworking.org/?fred=%E2%98%84", iri2uri(u"http://bitworking.org/?fred=\N{COMET}")) + self.assertEqual("http://bitworking.org/#%E2%98%84", iri2uri(u"http://bitworking.org/#\N{COMET}")) + self.assertEqual("#%E2%98%84", iri2uri(u"#\N{COMET}")) + self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}")) + self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}"))) + self.assertNotEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}".encode('utf-8'))) + + unittest.main() + + diff --git a/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base new file mode 100644 index 0000000..e16a3db --- /dev/null +++ b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base Binary files differ diff --git a/tutorius/apilib/httplib2/__init__.py b/tutorius/apilib/httplib2/__init__.py new file mode 100644 index 0000000..982bf8a --- /dev/null +++ b/tutorius/apilib/httplib2/__init__.py @@ -0,0 +1,1123 @@ +from __future__ import generators +""" +httplib2 + +A caching http interface that supports ETags and gzip +to conserve bandwidth. + +Requires Python 2.3 or later + +Changelog: +2007-08-18, Rick: Modified so it's able to use a socks proxy if needed. + +""" + +__author__ = "Joe Gregorio (joe@bitworking.org)" +__copyright__ = "Copyright 2006, Joe Gregorio" +__contributors__ = ["Thomas Broyer (t.broyer@ltgt.net)", + "James Antill", + "Xavier Verges Farrero", + "Jonathan Feinberg", + "Blair Zajac", + "Sam Ruby", + "Louis Nyffenegger"] +__license__ = "MIT" +__version__ = "$Rev: 259 $" + +import re +import sys +import md5 +import email +import email.Utils +import email.Message +import StringIO +import gzip +import zlib +import httplib +import urlparse +import base64 +import os +import copy +import calendar +import time +import random +import sha +import hmac +from gettext import gettext as _ +import socket + +try: + import socks +except ImportError: + socks = None + +if sys.version_info >= (2,3): + from iri2uri import iri2uri +else: + def iri2uri(uri): + return uri + +__all__ = ['Http', 'Response', 'ProxyInfo', 'HttpLib2Error', + 'RedirectMissingLocation', 'RedirectLimit', 'FailedToDecompressContent', + 'UnimplementedDigestAuthOptionError', 'UnimplementedHmacDigestAuthOptionError', + 'debuglevel'] + + +# The httplib debug level, set to a non-zero value to get debug output +debuglevel = 0 + +# Python 2.3 support +if sys.version_info < (2,4): + def sorted(seq): + seq.sort() + return seq + +# Python 2.3 support +def HTTPResponse__getheaders(self): + """Return list of (header, value) tuples.""" + if self.msg is None: + raise httplib.ResponseNotReady() + return self.msg.items() + +if not hasattr(httplib.HTTPResponse, 'getheaders'): + httplib.HTTPResponse.getheaders = HTTPResponse__getheaders + +# All exceptions raised here derive from HttpLib2Error +class HttpLib2Error(Exception): pass + +# Some exceptions can be caught and optionally +# be turned back into responses. +class HttpLib2ErrorWithResponse(HttpLib2Error): + def __init__(self, desc, response, content): + self.response = response + self.content = content + HttpLib2Error.__init__(self, desc) + +class RedirectMissingLocation(HttpLib2ErrorWithResponse): pass +class RedirectLimit(HttpLib2ErrorWithResponse): pass +class FailedToDecompressContent(HttpLib2ErrorWithResponse): pass +class UnimplementedDigestAuthOptionError(HttpLib2ErrorWithResponse): pass +class UnimplementedHmacDigestAuthOptionError(HttpLib2ErrorWithResponse): pass + +class RelativeURIError(HttpLib2Error): pass +class ServerNotFoundError(HttpLib2Error): pass + +# Open Items: +# ----------- +# Proxy support + +# Are we removing the cached content too soon on PUT (only delete on 200 Maybe?) + +# Pluggable cache storage (supports storing the cache in +# flat files by default. We need a plug-in architecture +# that can support Berkeley DB and Squid) + +# == Known Issues == +# Does not handle a resource that uses conneg and Last-Modified but no ETag as a cache validator. +# Does not handle Cache-Control: max-stale +# Does not use Age: headers when calculating cache freshness. + + +# The number of redirections to follow before giving up. +# Note that only GET redirects are automatically followed. +# Will also honor 301 requests by saving that info and never +# requesting that URI again. +DEFAULT_MAX_REDIRECTS = 5 + +# Which headers are hop-by-hop headers by default +HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade'] + +def _get_end2end_headers(response): + hopbyhop = list(HOP_BY_HOP) + hopbyhop.extend([x.strip() for x in response.get('connection', '').split(',')]) + return [header for header in response.keys() if header not in hopbyhop] + +URI = re.compile(r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?") + +def parse_uri(uri): + """Parses a URI using the regex given in Appendix B of RFC 3986. + + (scheme, authority, path, query, fragment) = parse_uri(uri) + """ + groups = URI.match(uri).groups() + return (groups[1], groups[3], groups[4], groups[6], groups[8]) + +def urlnorm(uri): + (scheme, authority, path, query, fragment) = parse_uri(uri) + if not scheme or not authority: + raise RelativeURIError("Only absolute URIs are allowed. uri = %s" % uri) + authority = authority.lower() + scheme = scheme.lower() + if not path: + path = "/" + # Could do syntax based normalization of the URI before + # computing the digest. See Section 6.2.2 of Std 66. + request_uri = query and "?".join([path, query]) or path + scheme = scheme.lower() + defrag_uri = scheme + "://" + authority + request_uri + return scheme, authority, request_uri, defrag_uri + + +# Cache filename construction (original borrowed from Venus http://intertwingly.net/code/venus/) +re_url_scheme = re.compile(r'^\w+://') +re_slash = re.compile(r'[?/:|]+') + +def safename(filename): + """Return a filename suitable for the cache. + + Strips dangerous and common characters to create a filename we + can use to store the cache in. + """ + + try: + if re_url_scheme.match(filename): + if isinstance(filename,str): + filename = filename.decode('utf-8') + filename = filename.encode('idna') + else: + filename = filename.encode('idna') + except UnicodeError: + pass + if isinstance(filename,unicode): + filename=filename.encode('utf-8') + filemd5 = md5.new(filename).hexdigest() + filename = re_url_scheme.sub("", filename) + filename = re_slash.sub(",", filename) + + # limit length of filename + if len(filename)>200: + filename=filename[:200] + return ",".join((filename, filemd5)) + +NORMALIZE_SPACE = re.compile(r'(?:\r\n)?[ \t]+') +def _normalize_headers(headers): + return dict([ (key.lower(), NORMALIZE_SPACE.sub(value, ' ').strip()) for (key, value) in headers.iteritems()]) + +def _parse_cache_control(headers): + retval = {} + if headers.has_key('cache-control'): + parts = headers['cache-control'].split(',') + parts_with_args = [tuple([x.strip() for x in part.split("=")]) for part in parts if -1 != part.find("=")] + parts_wo_args = [(name.strip(), 1) for name in parts if -1 == name.find("=")] + retval = dict(parts_with_args + parts_wo_args) + return retval + +# Whether to use a strict mode to parse WWW-Authenticate headers +# Might lead to bad results in case of ill-formed header value, +# so disabled by default, falling back to relaxed parsing. +# Set to true to turn on, usefull for testing servers. +USE_WWW_AUTH_STRICT_PARSING = 0 + +# In regex below: +# [^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+ matches a "token" as defined by HTTP +# "(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?" matches a "quoted-string" as defined by HTTP, when LWS have already been replaced by a single space +# Actually, as an auth-param value can be either a token or a quoted-string, they are combined in a single pattern which matches both: +# \"?((?<=\")(?:[^\0-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?@,;:\\\"/[\]?={} \t]+(?!\"))\"? +WWW_AUTH_STRICT = re.compile(r"^(?:\s*(?:,\s*)?([^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+)\s*=\s*\"?((?<=\")(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?@,;:\\\"/[\]?={} \t]+(?!\"))\"?)(.*)$") +WWW_AUTH_RELAXED = re.compile(r"^(?:\s*(?:,\s*)?([^ \t\r\n=]+)\s*=\s*\"?((?<=\")(?:[^\\\"]|\\.)*?(?=\")|(? current_age: + retval = "FRESH" + return retval + +def _decompressContent(response, new_content): + content = new_content + try: + encoding = response.get('content-encoding', None) + if encoding in ['gzip', 'deflate']: + if encoding == 'gzip': + content = gzip.GzipFile(fileobj=StringIO.StringIO(new_content)).read() + if encoding == 'deflate': + content = zlib.decompress(content) + response['content-length'] = str(len(content)) + del response['content-encoding'] + except IOError: + content = "" + raise FailedToDecompressContent(_("Content purported to be compressed with %s but failed to decompress.") % response.get('content-encoding'), response, content) + return content + +def _updateCache(request_headers, response_headers, content, cache, cachekey): + if cachekey: + cc = _parse_cache_control(request_headers) + cc_response = _parse_cache_control(response_headers) + if cc.has_key('no-store') or cc_response.has_key('no-store'): + cache.delete(cachekey) + else: + info = email.Message.Message() + for key, value in response_headers.iteritems(): + if key not in ['status','content-encoding','transfer-encoding']: + info[key] = value + + status = response_headers.status + if status == 304: + status = 200 + + status_header = 'status: %d\r\n' % response_headers.status + + header_str = info.as_string() + + header_str = re.sub("\r(?!\n)|(? 0: + service = "cl" + # No point in guessing Base or Spreadsheet + #elif request_uri.find("spreadsheets") > 0: + # service = "wise" + + auth = dict(Email=credentials[0], Passwd=credentials[1], service=service, source=headers['user-agent']) + resp, content = self.http.request("https://www.google.com/accounts/ClientLogin", method="POST", body=urlencode(auth), headers={'Content-Type': 'application/x-www-form-urlencoded'}) + lines = content.split('\n') + d = dict([tuple(line.split("=", 1)) for line in lines if line]) + if resp.status == 403: + self.Auth = "" + else: + self.Auth = d['Auth'] + + def request(self, method, request_uri, headers, content): + """Modify the request headers to add the appropriate + Authorization header.""" + headers['authorization'] = 'GoogleLogin Auth=' + self.Auth + + +AUTH_SCHEME_CLASSES = { + "basic": BasicAuthentication, + "wsse": WsseAuthentication, + "digest": DigestAuthentication, + "hmacdigest": HmacDigestAuthentication, + "googlelogin": GoogleLoginAuthentication +} + +AUTH_SCHEME_ORDER = ["hmacdigest", "googlelogin", "digest", "wsse", "basic"] + +def _md5(s): + return + +class FileCache(object): + """Uses a local directory as a store for cached files. + Not really safe to use if multiple threads or processes are going to + be running on the same cache. + """ + def __init__(self, cache, safe=safename): # use safe=lambda x: md5.new(x).hexdigest() for the old behavior + self.cache = cache + self.safe = safe + if not os.path.exists(cache): + os.makedirs(self.cache) + + def get(self, key): + retval = None + cacheFullPath = os.path.join(self.cache, self.safe(key)) + try: + f = file(cacheFullPath, "r") + retval = f.read() + f.close() + except IOError: + pass + return retval + + def set(self, key, value): + cacheFullPath = os.path.join(self.cache, self.safe(key)) + f = file(cacheFullPath, "w") + f.write(value) + f.close() + + def delete(self, key): + cacheFullPath = os.path.join(self.cache, self.safe(key)) + if os.path.exists(cacheFullPath): + os.remove(cacheFullPath) + +class Credentials(object): + def __init__(self): + self.credentials = [] + + def add(self, name, password, domain=""): + self.credentials.append((domain.lower(), name, password)) + + def clear(self): + self.credentials = [] + + def iter(self, domain): + for (cdomain, name, password) in self.credentials: + if cdomain == "" or domain == cdomain: + yield (name, password) + +class KeyCerts(Credentials): + """Identical to Credentials except that + name/password are mapped to key/cert.""" + pass + + +class ProxyInfo(object): + """Collect information required to use a proxy.""" + def __init__(self, proxy_type, proxy_host, proxy_port, proxy_rdns=None, proxy_user=None, proxy_pass=None): + """The parameter proxy_type must be set to one of socks.PROXY_TYPE_XXX + constants. For example: + +p = ProxyInfo(proxy_type=socks.PROXY_TYPE_HTTP, proxy_host='localhost', proxy_port=8000) + """ + self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, self.proxy_user, self.proxy_pass = proxy_type, proxy_host, proxy_port, proxy_rdns, proxy_user, proxy_pass + + def astuple(self): + return (self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, + self.proxy_user, self.proxy_pass) + + def isgood(self): + return socks and (self.proxy_host != None) and (self.proxy_port != None) + + +class HTTPConnectionWithTimeout(httplib.HTTPConnection): + """HTTPConnection subclass that supports timeouts""" + + def __init__(self, host, port=None, strict=None, timeout=None, proxy_info=None): + httplib.HTTPConnection.__init__(self, host, port, strict) + self.timeout = timeout + self.proxy_info = proxy_info + + def connect(self): + """Connect to the host and port specified in __init__.""" + # Mostly verbatim from httplib.py. + msg = "getaddrinfo returns an empty list" + for res in socket.getaddrinfo(self.host, self.port, 0, + socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + try: + if self.proxy_info and self.proxy_info.isgood(): + self.sock = socks.socksocket(af, socktype, proto) + self.sock.setproxy(*self.proxy_info.astuple()) + else: + self.sock = socket.socket(af, socktype, proto) + # Different from httplib: support timeouts. + if self.timeout is not None: + self.sock.settimeout(self.timeout) + # End of difference from httplib. + if self.debuglevel > 0: + print "connect: (%s, %s)" % (self.host, self.port) + self.sock.connect(sa) + except socket.error, msg: + if self.debuglevel > 0: + print 'connect fail:', (self.host, self.port) + if self.sock: + self.sock.close() + self.sock = None + continue + break + if not self.sock: + raise socket.error, msg + +class HTTPSConnectionWithTimeout(httplib.HTTPSConnection): + "This class allows communication via SSL." + + def __init__(self, host, port=None, key_file=None, cert_file=None, + strict=None, timeout=None, proxy_info=None): + self.timeout = timeout + self.proxy_info = proxy_info + httplib.HTTPSConnection.__init__(self, host, port=port, key_file=key_file, + cert_file=cert_file, strict=strict) + + def connect(self): + "Connect to a host on a given (SSL) port." + + if self.proxy_info and self.proxy_info.isgood(): + self.sock.setproxy(*self.proxy_info.astuple()) + sock.setproxy(*self.proxy_info.astuple()) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if self.timeout is not None: + sock.settimeout(self.timeout) + sock.connect((self.host, self.port)) + ssl = socket.ssl(sock, self.key_file, self.cert_file) + self.sock = httplib.FakeSocket(sock, ssl) + + + +class Http(object): + """An HTTP client that handles: +- all methods +- caching +- ETags +- compression, +- HTTPS +- Basic +- Digest +- WSSE + +and more. + """ + def __init__(self, cache=None, timeout=None, proxy_info=None): + """The value of proxy_info is a ProxyInfo instance. + +If 'cache' is a string then it is used as a directory name +for a disk cache. Otherwise it must be an object that supports +the same interface as FileCache.""" + self.proxy_info = proxy_info + # Map domain name to an httplib connection + self.connections = {} + # The location of the cache, for now a directory + # where cached responses are held. + if cache and isinstance(cache, str): + self.cache = FileCache(cache) + else: + self.cache = cache + + # Name/password + self.credentials = Credentials() + + # Key/cert + self.certificates = KeyCerts() + + # authorization objects + self.authorizations = [] + + # If set to False then no redirects are followed, even safe ones. + self.follow_redirects = True + + # If 'follow_redirects' is True, and this is set to True then + # all redirecs are followed, including unsafe ones. + self.follow_all_redirects = False + + self.ignore_etag = False + + self.force_exception_to_status_code = False + + self.timeout = timeout + + def _auth_from_challenge(self, host, request_uri, headers, response, content): + """A generator that creates Authorization objects + that can be applied to requests. + """ + challenges = _parse_www_authenticate(response, 'www-authenticate') + for cred in self.credentials.iter(host): + for scheme in AUTH_SCHEME_ORDER: + if challenges.has_key(scheme): + yield AUTH_SCHEME_CLASSES[scheme](cred, host, request_uri, headers, response, content, self) + + def add_credentials(self, name, password, domain=""): + """Add a name and password that will be used + any time a request requires authentication.""" + self.credentials.add(name, password, domain) + + def add_certificate(self, key, cert, domain): + """Add a key and cert that will be used + any time a request requires authentication.""" + self.certificates.add(key, cert, domain) + + def clear_credentials(self): + """Remove all the names and passwords + that are used for authentication""" + self.credentials.clear() + self.authorizations = [] + + def _conn_request(self, conn, request_uri, method, body, headers): + for i in range(2): + try: + conn.request(method, request_uri, body, headers) + response = conn.getresponse() + except socket.gaierror: + conn.close() + raise ServerNotFoundError("Unable to find the server at %s" % conn.host) + except httplib.HTTPException, e: + if i == 0: + conn.close() + conn.connect() + continue + else: + raise + else: + content = response.read() + response = Response(response) + if method != "HEAD": + content = _decompressContent(response, content) + + break; + return (response, content) + + + def _request(self, conn, host, absolute_uri, request_uri, method, body, headers, redirections, cachekey): + """Do the actual request using the connection object + and also follow one level of redirects if necessary""" + + auths = [(auth.depth(request_uri), auth) for auth in self.authorizations if auth.inscope(host, request_uri)] + auth = auths and sorted(auths)[0][1] or None + if auth: + auth.request(method, request_uri, headers, body) + + (response, content) = self._conn_request(conn, request_uri, method, body, headers) + + if auth: + if auth.response(response, body): + auth.request(method, request_uri, headers, body) + (response, content) = self._conn_request(conn, request_uri, method, body, headers ) + response._stale_digest = 1 + + if response.status == 401: + for authorization in self._auth_from_challenge(host, request_uri, headers, response, content): + authorization.request(method, request_uri, headers, body) + (response, content) = self._conn_request(conn, request_uri, method, body, headers, ) + if response.status != 401: + self.authorizations.append(authorization) + authorization.response(response, body) + break + + if (self.follow_all_redirects or (method in ["GET", "HEAD"]) or response.status == 303): + if self.follow_redirects and response.status in [300, 301, 302, 303, 307]: + # Pick out the location header and basically start from the beginning + # remembering first to strip the ETag header and decrement our 'depth' + if redirections: + if not response.has_key('location') and response.status != 300: + raise RedirectMissingLocation( _("Redirected but the response is missing a Location: header."), response, content) + # Fix-up relative redirects (which violate an RFC 2616 MUST) + if response.has_key('location'): + location = response['location'] + (scheme, authority, path, query, fragment) = parse_uri(location) + if authority == None: + response['location'] = urlparse.urljoin(absolute_uri, location) + if response.status == 301 and method in ["GET", "HEAD"]: + response['-x-permanent-redirect-url'] = response['location'] + if not response.has_key('content-location'): + response['content-location'] = absolute_uri + _updateCache(headers, response, content, self.cache, cachekey) + if headers.has_key('if-none-match'): + del headers['if-none-match'] + if headers.has_key('if-modified-since'): + del headers['if-modified-since'] + if response.has_key('location'): + location = response['location'] + old_response = copy.deepcopy(response) + if not old_response.has_key('content-location'): + old_response['content-location'] = absolute_uri + redirect_method = ((response.status == 303) and (method not in ["GET", "HEAD"])) and "GET" or method + (response, content) = self.request(location, redirect_method, body=body, headers = headers, redirections = redirections - 1) + response.previous = old_response + else: + raise RedirectLimit( _("Redirected more times than rediection_limit allows."), response, content) + elif response.status in [200, 203] and method == "GET": + # Don't cache 206's since we aren't going to handle byte range requests + if not response.has_key('content-location'): + response['content-location'] = absolute_uri + _updateCache(headers, response, content, self.cache, cachekey) + + return (response, content) + + +# Need to catch and rebrand some exceptions +# Then need to optionally turn all exceptions into status codes +# including all socket.* and httplib.* exceptions. + + + def request(self, uri, method="GET", body=None, headers=None, redirections=DEFAULT_MAX_REDIRECTS, connection_type=None): + """ Performs a single HTTP request. +The 'uri' is the URI of the HTTP resource and can begin +with either 'http' or 'https'. The value of 'uri' must be an absolute URI. + +The 'method' is the HTTP method to perform, such as GET, POST, DELETE, etc. +There is no restriction on the methods allowed. + +The 'body' is the entity body to be sent with the request. It is a string +object. + +Any extra headers that are to be sent with the request should be provided in the +'headers' dictionary. + +The maximum number of redirect to follow before raising an +exception is 'redirections. The default is 5. + +The return value is a tuple of (response, content), the first +being and instance of the 'Response' class, the second being +a string that contains the response entity body. + """ + try: + if headers is None: + headers = {} + else: + headers = _normalize_headers(headers) + + if not headers.has_key('user-agent'): + headers['user-agent'] = "Python-httplib2/%s" % __version__ + + uri = iri2uri(uri) + + (scheme, authority, request_uri, defrag_uri) = urlnorm(uri) + + conn_key = scheme+":"+authority + if conn_key in self.connections: + conn = self.connections[conn_key] + else: + if not connection_type: + connection_type = (scheme == 'https') and HTTPSConnectionWithTimeout or HTTPConnectionWithTimeout + certs = list(self.certificates.iter(authority)) + if scheme == 'https' and certs: + conn = self.connections[conn_key] = connection_type(authority, key_file=certs[0][0], + cert_file=certs[0][1], timeout=self.timeout, proxy_info=self.proxy_info) + else: + conn = self.connections[conn_key] = connection_type(authority, timeout=self.timeout, proxy_info=self.proxy_info) + conn.set_debuglevel(debuglevel) + + if method in ["GET", "HEAD"] and 'range' not in headers: + headers['accept-encoding'] = 'compress, gzip' + + info = email.Message.Message() + cached_value = None + if self.cache: + cachekey = defrag_uri + cached_value = self.cache.get(cachekey) + if cached_value: + info = email.message_from_string(cached_value) + try: + content = cached_value.split('\r\n\r\n', 1)[1] + except IndexError: + self.cache.delete(cachekey) + cachekey = None + cached_value = None + else: + cachekey = None + + if method in ["PUT"] and self.cache and info.has_key('etag') and not self.ignore_etag and 'if-match' not in headers: + # http://www.w3.org/1999/04/Editing/ + headers['if-match'] = info['etag'] + + if method not in ["GET", "HEAD"] and self.cache and cachekey: + # RFC 2616 Section 13.10 + self.cache.delete(cachekey) + + if cached_value and method in ["GET", "HEAD"] and self.cache and 'range' not in headers: + if info.has_key('-x-permanent-redirect-url'): + # Should cached permanent redirects be counted in our redirection count? For now, yes. + (response, new_content) = self.request(info['-x-permanent-redirect-url'], "GET", headers = headers, redirections = redirections - 1) + response.previous = Response(info) + response.previous.fromcache = True + else: + # Determine our course of action: + # Is the cached entry fresh or stale? + # Has the client requested a non-cached response? + # + # There seems to be three possible answers: + # 1. [FRESH] Return the cache entry w/o doing a GET + # 2. [STALE] Do the GET (but add in cache validators if available) + # 3. [TRANSPARENT] Do a GET w/o any cache validators (Cache-Control: no-cache) on the request + entry_disposition = _entry_disposition(info, headers) + + if entry_disposition == "FRESH": + if not cached_value: + info['status'] = '504' + content = "" + response = Response(info) + if cached_value: + response.fromcache = True + return (response, content) + + if entry_disposition == "STALE": + if info.has_key('etag') and not self.ignore_etag and not 'if-none-match' in headers: + headers['if-none-match'] = info['etag'] + if info.has_key('last-modified') and not 'last-modified' in headers: + headers['if-modified-since'] = info['last-modified'] + elif entry_disposition == "TRANSPARENT": + pass + + (response, new_content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey) + + if response.status == 304 and method == "GET": + # Rewrite the cache entry with the new end-to-end headers + # Take all headers that are in response + # and overwrite their values in info. + # unless they are hop-by-hop, or are listed in the connection header. + + for key in _get_end2end_headers(response): + info[key] = response[key] + merged_response = Response(info) + if hasattr(response, "_stale_digest"): + merged_response._stale_digest = response._stale_digest + _updateCache(headers, merged_response, content, self.cache, cachekey) + response = merged_response + response.status = 200 + response.fromcache = True + + elif response.status == 200: + content = new_content + else: + self.cache.delete(cachekey) + content = new_content + else: + (response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey) + except Exception, e: + if self.force_exception_to_status_code: + if isinstance(e, HttpLib2ErrorWithResponse): + response = e.response + content = e.content + response.status = 500 + response.reason = str(e) + elif isinstance(e, socket.timeout): + content = "Request Timeout" + response = Response( { + "content-type": "text/plain", + "status": "408", + "content-length": len(content) + }) + response.reason = "Request Timeout" + else: + content = str(e) + response = Response( { + "content-type": "text/plain", + "status": "400", + "content-length": len(content) + }) + response.reason = "Bad Request" + else: + raise + + + return (response, content) + + + +class Response(dict): + """An object more like email.Message than httplib.HTTPResponse.""" + + """Is this response from our local cache""" + fromcache = False + + """HTTP protocol version used by server. 10 for HTTP/1.0, 11 for HTTP/1.1. """ + version = 11 + + "Status code returned by server. " + status = 200 + + """Reason phrase returned by server.""" + reason = "Ok" + + previous = None + + def __init__(self, info): + # info is either an email.Message or + # an httplib.HTTPResponse object. + if isinstance(info, httplib.HTTPResponse): + for key, value in info.getheaders(): + self[key] = value + self.status = info.status + self['status'] = str(self.status) + self.reason = info.reason + self.version = info.version + elif isinstance(info, email.Message.Message): + for key, value in info.items(): + self[key] = value + self.status = int(self['status']) + else: + for key, value in info.iteritems(): + self[key] = value + self.status = int(self.get('status', self.status)) + + + def __getattr__(self, name): + if name == 'dict': + return self + else: + raise AttributeError, name diff --git a/tutorius/apilib/httplib2/__init__.pyc b/tutorius/apilib/httplib2/__init__.pyc new file mode 100644 index 0000000..e5f8ebe --- /dev/null +++ b/tutorius/apilib/httplib2/__init__.pyc Binary files differ diff --git a/tutorius/apilib/httplib2/iri2uri.py b/tutorius/apilib/httplib2/iri2uri.py new file mode 100644 index 0000000..70667ed --- /dev/null +++ b/tutorius/apilib/httplib2/iri2uri.py @@ -0,0 +1,110 @@ +""" +iri2uri + +Converts an IRI to a URI. + +""" +__author__ = "Joe Gregorio (joe@bitworking.org)" +__copyright__ = "Copyright 2006, Joe Gregorio" +__contributors__ = [] +__version__ = "1.0.0" +__license__ = "MIT" +__history__ = """ +""" + +import urlparse + + +# Convert an IRI to a URI following the rules in RFC 3987 +# +# The characters we need to enocde and escape are defined in the spec: +# +# iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD +# ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF +# / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD +# / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD +# / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD +# / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD +# / %xD0000-DFFFD / %xE1000-EFFFD + +escape_range = [ + (0xA0, 0xD7FF ), + (0xE000, 0xF8FF ), + (0xF900, 0xFDCF ), + (0xFDF0, 0xFFEF), + (0x10000, 0x1FFFD ), + (0x20000, 0x2FFFD ), + (0x30000, 0x3FFFD), + (0x40000, 0x4FFFD ), + (0x50000, 0x5FFFD ), + (0x60000, 0x6FFFD), + (0x70000, 0x7FFFD ), + (0x80000, 0x8FFFD ), + (0x90000, 0x9FFFD), + (0xA0000, 0xAFFFD ), + (0xB0000, 0xBFFFD ), + (0xC0000, 0xCFFFD), + (0xD0000, 0xDFFFD ), + (0xE1000, 0xEFFFD), + (0xF0000, 0xFFFFD ), + (0x100000, 0x10FFFD) +] + +def encode(c): + retval = c + i = ord(c) + for low, high in escape_range: + if i < low: + break + if i >= low and i <= high: + retval = "".join(["%%%2X" % ord(o) for o in c.encode('utf-8')]) + break + return retval + + +def iri2uri(uri): + """Convert an IRI to a URI. Note that IRIs must be + passed in a unicode strings. That is, do not utf-8 encode + the IRI before passing it into the function.""" + if isinstance(uri ,unicode): + (scheme, authority, path, query, fragment) = urlparse.urlsplit(uri) + authority = authority.encode('idna') + # For each character in 'ucschar' or 'iprivate' + # 1. encode as utf-8 + # 2. then %-encode each octet of that utf-8 + uri = urlparse.urlunsplit((scheme, authority, path, query, fragment)) + uri = "".join([encode(c) for c in uri]) + return uri + +if __name__ == "__main__": + import unittest + + class Test(unittest.TestCase): + + def test_uris(self): + """Test that URIs are invariant under the transformation.""" + invariant = [ + u"ftp://ftp.is.co.za/rfc/rfc1808.txt", + u"http://www.ietf.org/rfc/rfc2396.txt", + u"ldap://[2001:db8::7]/c=GB?objectClass?one", + u"mailto:John.Doe@example.com", + u"news:comp.infosystems.www.servers.unix", + u"tel:+1-816-555-1212", + u"telnet://192.0.2.16:80/", + u"urn:oasis:names:specification:docbook:dtd:xml:4.1.2" ] + for uri in invariant: + self.assertEqual(uri, iri2uri(uri)) + + def test_iri(self): + """ Test that the right type of escaping is done for each part of the URI.""" + self.assertEqual("http://xn--o3h.com/%E2%98%84", iri2uri(u"http://\N{COMET}.com/\N{COMET}")) + self.assertEqual("http://bitworking.org/?fred=%E2%98%84", iri2uri(u"http://bitworking.org/?fred=\N{COMET}")) + self.assertEqual("http://bitworking.org/#%E2%98%84", iri2uri(u"http://bitworking.org/#\N{COMET}")) + self.assertEqual("#%E2%98%84", iri2uri(u"#\N{COMET}")) + self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}")) + self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}"))) + self.assertNotEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}".encode('utf-8'))) + + unittest.main() + + diff --git a/tutorius/apilib/httplib2/iri2uri.pyc b/tutorius/apilib/httplib2/iri2uri.pyc new file mode 100644 index 0000000..879e719 --- /dev/null +++ b/tutorius/apilib/httplib2/iri2uri.pyc Binary files differ diff --git a/tutorius/apilib/mimeTypes.py b/tutorius/apilib/mimeTypes.py new file mode 100644 index 0000000..ff8f641 --- /dev/null +++ b/tutorius/apilib/mimeTypes.py @@ -0,0 +1,57 @@ +""" + Copyright (C) 2008 Benjamin O'Steen + + This file is part of python-fedoracommons. + + python-fedoracommons is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + python-fedoracommons is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with python-fedoracommons. If not, see . +""" + +__license__ = 'GPL http://www.gnu.org/licenses/gpl.txt' +__author__ = "Benjamin O'Steen " +__version__ = '0.1' + +class mimeTypes(object): + def getDictionary(self): + mimetype_to_extension = {} + extension_to_mimetype = {} + mimetype_to_extension['text/plain'] = 'txt' + mimetype_to_extension['text/xml'] = 'xml' + mimetype_to_extension['text/css'] = 'css' + mimetype_to_extension['text/javascript'] = 'js' + mimetype_to_extension['text/rtf'] = 'rtf' + mimetype_to_extension['text/calendar'] = 'ics' + mimetype_to_extension['application/msword'] = 'doc' + mimetype_to_extension['application/msexcel'] = 'xls' + mimetype_to_extension['application/x-msword'] = 'doc' + mimetype_to_extension['application/vnd.ms-excel'] = 'xls' + mimetype_to_extension['application/vnd.ms-powerpoint'] = 'ppt' + mimetype_to_extension['application/pdf'] = 'pdf' + mimetype_to_extension['text/comma-separated-values'] = 'csv' + + + mimetype_to_extension['image/jpeg'] = 'jpg' + mimetype_to_extension['image/gif'] = 'gif' + mimetype_to_extension['image/jpg'] = 'jpg' + mimetype_to_extension['image/tiff'] = 'tiff' + mimetype_to_extension['image/png'] = 'png' + + # And hacky reverse lookups + for mimetype in mimetype_to_extension: + extension_to_mimetype[mimetype_to_extension[mimetype]] = mimetype + + mimetype_extension_mapping = {} + mimetype_extension_mapping.update(mimetype_to_extension) + mimetype_extension_mapping.update(extension_to_mimetype) + + return mimetype_extension_mapping diff --git a/tutorius/apilib/mimeTypes.pyc b/tutorius/apilib/mimeTypes.pyc new file mode 100644 index 0000000..35ef2b2 --- /dev/null +++ b/tutorius/apilib/mimeTypes.pyc Binary files differ diff --git a/tutorius/apilib/restful_lib.py b/tutorius/apilib/restful_lib.py new file mode 100644 index 0000000..e1ee0af --- /dev/null +++ b/tutorius/apilib/restful_lib.py @@ -0,0 +1,129 @@ +""" + Copyright (C) 2008 Benjamin O'Steen + + This file is part of python-fedoracommons. + + python-fedoracommons is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + python-fedoracommons is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with python-fedoracommons. If not, see . +""" + +__license__ = 'GPL http://www.gnu.org/licenses/gpl.txt' +__author__ = "Benjamin O'Steen " +__version__ = '0.1' + +import httplib2 +import urlparse +import urllib +import base64 +from base64 import encodestring + +from mimeTypes import * + +import mimetypes + +from cStringIO import StringIO + +class Connection: + def __init__(self, base_url, username=None, password=None): + self.base_url = base_url + self.username = username + m = mimeTypes() + self.mimetypes = m.getDictionary() + + self.url = urlparse.urlparse(base_url) + + (scheme, netloc, path, query, fragment) = urlparse.urlsplit(base_url) + + self.scheme = scheme + self.host = netloc + self.path = path + + # Create Http class with support for Digest HTTP Authentication, if necessary + self.h = httplib2.Http(".cache") + self.h.follow_all_redirects = True + if username and password: + self.h.add_credentials(username, password) + + def request_get(self, resource, args = None, headers={}): + return self.request(resource, "get", args, headers=headers) + + def request_delete(self, resource, args = None, headers={}): + return self.request(resource, "delete", args, headers=headers) + + def request_head(self, resource, args = None, headers={}): + return self.request(resource, "head", args, headers=headers) + + def request_post(self, resource, args = None, body = None, filename=None, headers={}): + return self.request(resource, "post", args , body = body, filename=filename, headers=headers) + + def request_put(self, resource, args = None, body = None, filename=None, headers={}): + return self.request(resource, "put", args , body = body, filename=filename, headers=headers) + + def get_content_type(self, filename): + extension = filename.split('.')[-1] + guessed_mimetype = self.mimetypes.get(extension, mimetypes.guess_type(filename)[0]) + return guessed_mimetype or 'application/octet-stream' + + def request(self, resource, method = "get", args = None, body = None, filename=None, headers={}): + params = None + path = resource + headers['User-Agent'] = 'Basic Agent' + + BOUNDARY = u'00hoYUXOnLD5RQ8SKGYVgLLt64jejnMwtO7q8XE1' + CRLF = u'\r\n' + + if filename and body: + #fn = open(filename ,'r') + #chunks = fn.read() + #fn.close() + + # Attempt to find the Mimetype + content_type = self.get_content_type(filename) + headers['Content-Type']='multipart/form-data; boundary='+BOUNDARY + encode_string = StringIO() + encode_string.write(CRLF) + encode_string.write(u'--' + BOUNDARY + CRLF) + encode_string.write(u'Content-Disposition: form-data; name="file"; filename="%s"' % filename) + encode_string.write(CRLF) + encode_string.write(u'Content-Type: %s' % content_type + CRLF) + encode_string.write(CRLF) + encode_string.write(body) + encode_string.write(CRLF) + encode_string.write(u'--' + BOUNDARY + u'--' + CRLF) + + body = encode_string.getvalue() + headers['Content-Length'] = str(len(body)) + elif body: + if not headers.get('Content-Type', None): + headers['Content-Type']='text/xml' + headers['Content-Length'] = str(len(body)) + else: + headers['Content-Type']='text/xml' + + if args: + path += u"?" + urllib.urlencode(args) + + request_path = [] + if self.path != "/": + if self.path.endswith('/'): + request_path.append(self.path[:-1]) + else: + request_path.append(self.path) + if path.startswith('/'): + request_path.append(path[1:]) + else: + request_path.append(path) + + resp, content = self.h.request(u"%s://%s%s" % (self.scheme, self.host, u'/'.join(request_path)), method.upper(), body=body, headers=headers ) + + return {u'headers':resp, u'body':content.decode('UTF-8')} diff --git a/tutorius/apilib/restful_lib.pyc b/tutorius/apilib/restful_lib.pyc new file mode 100644 index 0000000..5b06765 --- /dev/null +++ b/tutorius/apilib/restful_lib.pyc Binary files differ diff --git a/tutorius/constraints.py b/tutorius/constraints.py index e91f23a..519bce8 100644 --- a/tutorius/constraints.py +++ b/tutorius/constraints.py @@ -25,6 +25,12 @@ for some properties. # For the File Constraint import os +class ConstraintException(Exception): + """ + Parent class for all constraint exceptions + """ + pass + class Constraint(): """ Basic block for defining constraints on a TutoriusProperty. Every class @@ -47,7 +53,7 @@ class ValueConstraint(Constraint): def __init__(self, limit): self.limit = limit -class UpperLimitConstraintError(Exception): +class UpperLimitConstraintError(ConstraintException): pass class UpperLimitConstraint(ValueConstraint): @@ -64,7 +70,7 @@ class UpperLimitConstraint(ValueConstraint): raise UpperLimitConstraintError() return -class LowerLimitConstraintError(Exception): +class LowerLimitConstraintError(ConstraintException): pass class LowerLimitConstraint(ValueConstraint): @@ -81,7 +87,7 @@ class LowerLimitConstraint(ValueConstraint): raise LowerLimitConstraintError() return -class MaxSizeConstraintError(Exception): +class MaxSizeConstraintError(ConstraintException): pass class MaxSizeConstraint(ValueConstraint): @@ -99,7 +105,7 @@ class MaxSizeConstraint(ValueConstraint): raise MaxSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit)) return -class MinSizeConstraintError(Exception): +class MinSizeConstraintError(ConstraintException): pass class MinSizeConstraint(ValueConstraint): @@ -117,7 +123,7 @@ class MinSizeConstraint(ValueConstraint): raise MinSizeConstraintError("Setter : trying to set value of length %d while limit is %d"%(len(value), self.limit)) return -class ColorConstraintError(Exception): +class ColorConstraintError(ConstraintException): pass class ColorArraySizeError(ColorConstraintError): @@ -153,7 +159,7 @@ class ColorConstraint(Constraint): return -class BooleanConstraintError(Exception): +class BooleanConstraintError(ConstraintException): pass class BooleanConstraint(Constraint): @@ -165,7 +171,7 @@ class BooleanConstraint(Constraint): return raise BooleanConstraintError("Value is not True or False") -class EnumConstraintError(Exception): +class EnumConstraintError(ConstraintException): pass class EnumConstraint(Constraint): @@ -190,7 +196,7 @@ class EnumConstraint(Constraint): raise EnumConstraintError("Value is not part of the enumeration") return -class FileConstraintError(Exception): +class FileConstraintError(ConstraintException): pass class FileConstraint(Constraint): diff --git a/tutorius/core.py b/tutorius/core.py index 15a0c87..bfbe07b 100644 --- a/tutorius/core.py +++ b/tutorius/core.py @@ -24,9 +24,9 @@ This module contains the core classes for tutorius import logging import os -from sugar.tutorius.TProbe import ProbeManager -from sugar.tutorius.dbustools import save_args -from sugar.tutorius import addon +from .TProbe import ProbeManager +from .dbustools import save_args +from . import addon logger = logging.getLogger("tutorius") @@ -271,7 +271,7 @@ class State(object): if len(self._actions) != len(otherState._actions): return False - if len(self._event_filters) != len(otherState._event_filters): + if len(self._transitions) != len(otherState._transitions): return False for act in self._actions: @@ -287,18 +287,8 @@ class State(object): return False # Do they have the same event filters? - 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 == otherEvent: - found = True - break - if found == False: - # We could not find the given event filter in the other state. - return False + if self._transitions != otherState._transitions: + return False # If nothing failed up to now, then every actions and every filters can # be found in the other state @@ -515,10 +505,9 @@ class FiniteStateMachine(State): #TODO : Move this code inside the State itself - we're breaking # encap :P - if st._transitions: - for event, state in st._transitions.items(): - if state == state_name: - del st._transitions[event] + for event in st._transitions: + if st._transitions[event] == state_name: + del st._transitions[event] # Remove the state from the dictionary del self._states[state_name] @@ -536,7 +525,7 @@ class FiniteStateMachine(State): next_states = set() - for event, state in state._transitions: + for event, state in state._transitions.items(): next_states.add(state) return tuple(next_states) @@ -559,7 +548,7 @@ class FiniteStateMachine(State): states = [] # Walk through the list of states for st in self._states.itervalues(): - for event, state in st._transitions: + for event, state in st._transitions.items(): if state == state_name: states.append(state) continue diff --git a/tutorius/creator.py b/tutorius/creator.py index efa17c3..c477056 100644 --- a/tutorius/creator.py +++ b/tutorius/creator.py @@ -22,16 +22,19 @@ the activity itself. # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA import gtk.gdk +import gtk.glade import gobject from gettext import gettext as T -from sugar.graphics.toolbutton import ToolButton +import os +from sugar.graphics import icon +import copy -from sugar.tutorius import overlayer, gtkutils, actions, bundler, properties, addon -from sugar.tutorius import filters -from sugar.tutorius.services import ObjectStore -from sugar.tutorius.linear_creator import LinearCreator -from sugar.tutorius.core import Tutorial +from . import overlayer, gtkutils, actions, vault, properties, addon +from . import filters +from .services import ObjectStore +from .core import Tutorial, FiniteStateMachine, State +from . import viewer class Creator(object): """ @@ -47,80 +50,162 @@ class Creator(object): """ self._activity = activity if not tutorial: - self._tutorial = LinearCreator() + self._tutorial = FiniteStateMachine('Untitled') + self._state = State(name='INIT') + self._tutorial.add_state(self._state) + self._state_counter = 1 else: self._tutorial = tutorial + # TODO load existing tutorial; unused yet self._action_panel = None self._current_filter = None self._intro_mask = None self._intro_handle = None - self._state_bubble = overlayer.TextBubble(self._tutorial.state_name) allocation = self._activity.get_allocation() self._width = allocation.width self._height = allocation.height self._selected_widget = None self._eventmenu = None + self.tuto = None + self._guid = None self._hlmask = overlayer.Rectangle(None, (1.0, 0.0, 0.0, 0.5)) self._activity._overlayer.put(self._hlmask, 0, 0) - self._activity._overlayer.put(self._state_bubble, - self._width/2-self._state_bubble.allocation.width/2, 0) - self._state_bubble.show() - dlg_width = 300 dlg_height = 70 sw = gtk.gdk.screen_width() sh = gtk.gdk.screen_height() - self._tooldialog = gtk.Window() - self._tooldialog.set_title("Tutorius tools") - self._tooldialog.set_transient_for(self._activity) - self._tooldialog.set_decorated(True) - self._tooldialog.set_resizable(False) - self._tooldialog.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_UTILITY) - self._tooldialog.set_destroy_with_parent(True) - self._tooldialog.set_deletable(False) - self._tooldialog.set_size_request(dlg_width, dlg_height) - - toolbar = gtk.Toolbar() - for tool in addon.list_addons(): - meta = addon.get_addon_meta(tool) - toolitem = ToolButton(meta['icon']) - toolitem.set_tooltip(meta['display_name']) - toolitem.connect("clicked", self._add_action_cb, tool) - toolbar.insert(toolitem, -1) - toolitem = ToolButton("go-next") - toolitem.connect("clicked", self._add_step_cb) - toolitem.set_tooltip("Add Step") - toolbar.insert(toolitem, -1) - toolitem = ToolButton("stop") - toolitem.connect("clicked", self._cleanup_cb) - toolitem.set_tooltip("End Tutorial") - toolbar.insert(toolitem, -1) - self._tooldialog.add(toolbar) - self._tooldialog.show_all() - # simpoir: I suspect the realized widget is a tiny bit larger than - # it should be, thus the -10. - self._tooldialog.move(sw-10-dlg_width, sh-dlg_height) - - self._propedit = EditToolBox(self._activity) - - def _evfilt_cb(self, menuitem, event_name, *args): + + self._propedit = ToolBox(self._activity) + self._propedit.tree.signal_autoconnect({ + 'on_quit_clicked': self._cleanup_cb, + 'on_save_clicked': self.save, + 'on_action_activate': self._add_action_cb, + 'on_event_activate': self._add_event_cb, + }) + self._propedit.window.move( + gtk.gdk.screen_width()-self._propedit.window.get_allocation().width, + 100) + + + self._overview = viewer.Viewer(self._tutorial, self) + self._overview.win.set_transient_for(self._activity) + + self._overview.win.move(0, gtk.gdk.screen_height()- \ + self._overview.win.get_allocation().height) + + self._transitions = dict() + + def _update_next_state(self, state, event, next_state): + self._transitions[event] = next_state + + evts = state.get_event_filter_list() + state.clear_event_filters() + for evt, next_state in evts: + state.add_event_filter(evt, self._transitions[evt]) + + def delete_action(self, action): + """ + Removes the first instance of specified action from the tutorial. + + @param action: the action object to remove from the tutorial + @returns: True if successful, otherwise False. + """ + state = self._tutorial.get_state_by_name("INIT") + + while True: + state_actions = state.get_action_list() + for fsm_action in state_actions: + if fsm_action is action: + state.clear_actions() + if state is self._state: + fsm_action.exit_editmode() + state_actions.remove(fsm_action) + self.set_insertion_point(state.name) + for keep_action in state_actions: + state.add_action(keep_action) + return True + + ev_list = state.get_event_filter_list() + if ev_list: + state = self._tutorial.get_state_by_name(ev_list[0][1]) + continue + + return False + + def delete_state(self): + """ + Remove current state. + Limitation: The last state cannot be removed, as it doesn't have + any transitions to remove anyway. + + @returns: True if successful, otherwise False. + """ + if not self._state.get_event_filter_list(): + # last state cannot be removed + return False + + state = self._tutorial.get_state_by_name("INIT") + ev_list = state.get_event_filter_list() + if state is self._state: + next_state = self._tutorial.get_state_by_name(ev_list[0][1]) + self.set_insertion_point(next_state.name) + self._tutorial.remove_state(state.name) + self._tutorial.remove_state(next_state.name) + next_state.name = "INIT" + self._tutorial.add_state(next_state) + return True + + # loop to repair links from deleted state + while ev_list: + next_state = self._tutorial.get_state_by_name(ev_list[0][1]) + if next_state is self._state: + # the tutorial will flush the event filters. We'll need to + # clear and re-add them. + self._tutorial.remove_state(self._state.name) + state.clear_event_filters() + self._update_next_state(state, ev_list[0][0], next_state.get_event_filter_list()[0][1]) + for ev, next_state in ev_list: + state.add_event_filter(ev, next_state) + + self.set_insertion_point(ev_list[0][1]) + return True + + state = next_state + ev_list = state.get_event_filter_list() + return False + + def get_insertion_point(self): + return self._state.name + + def set_insertion_point(self, state_name): + for action in self._state.get_action_list(): + action.exit_editmode() + self._state = self._tutorial.get_state_by_name(state_name) + self._overview.win.queue_draw() + state_actions = self._state.get_action_list() + for action in state_actions: + action.enter_editmode() + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + + if state_actions: + self._propedit.action = state_actions[0] + else: + self._propedit.action = None + + + def _evfilt_cb(self, menuitem, event): """ This will get called once the user has selected a menu item from the event filter popup menu. This should add the correct event filter to the FSM and increment states. """ - self.introspecting = False - eventfilter = addon.create('GtkWidgetEventFilter', - object_id=self._selected_widget, - event_name=event_name) # undo actions so they don't persist through step editing - for action in self._tutorial.current_actions: + for action in self._state.get_action_list(): action.exit_editmode() - self._tutorial.event(eventfilter) - self._state_bubble.label = self._tutorial.state_name self._hlmask.covered = None self._propedit.action = None self._activity.queue_draw() @@ -159,67 +244,70 @@ class Creator(object): self._eventmenu.popup(None, None, None, evt.button, evt.time) self._activity.queue_draw() - def set_intropecting(self, value): - """ - Set whether creator is in UI introspection mode. Setting this will - connect necessary handlers. - @param value True to setup introspection handlers. - """ - if bool(value) ^ bool(self._intro_mask): - if value: - self._intro_mask = overlayer.Mask(catch_events=True) - self._intro_handle = self._intro_mask.connect_after( - "button-press-event", self._intro_cb) - self._activity._overlayer.put(self._intro_mask, 0, 0) - else: - self._intro_mask.catch_events = False - self._intro_mask.disconnect(self._intro_handle) - self._intro_handle = None - self._activity._overlayer.remove(self._intro_mask) - self._intro_mask = None - - def get_introspecting(self): - """ - Whether creator is in UI introspection (catch all event) mode. - @return True if introspection handlers are connected, or False if not. - """ - return bool(self._intro_mask) - - introspecting = property(fset=set_intropecting, fget=get_introspecting) - - def _add_action_cb(self, widget, actiontype): + def _add_action_cb(self, widget, path): """Callback for the action creation toolbar tool""" - action = addon.create(actiontype) - if isinstance(action, actions.Action): - action.enter_editmode() - self._tutorial.action(action) - # FIXME: replace following with event catching - action._drag._eventbox.connect_after( - "button-release-event", self._action_refresh_cb, action) + action_type = self._propedit.actions_list[path][ToolBox.ICON_NAME] + action = addon.create(action_type) + action.enter_editmode() + self._state.add_action(action) + # FIXME: replace following with event catching + action._drag._eventbox.connect_after( + "button-release-event", self._action_refresh_cb, action) + self._overview.win.queue_draw() + + def _add_event_cb(self, widget, path): + """Callback for the event creation toolbar tool""" + event_type = self._propedit.events_list[path][ToolBox.ICON_NAME] + event = addon.create(event_type) + addonname = type(event).__name__ + meta = addon.get_addon_meta(addonname) + for propname in meta['mandatory_props']: + prop = getattr(type(event), propname) + if isinstance(prop, properties.TUAMProperty): + selector = WidgetSelector(self._activity) + setattr(event, propname, selector.select()) + elif isinstance(prop, properties.TEventType): + try: + dlg = SignalInputDialog(self._activity, + text="Mandatory property", + field=propname, + addr=event.object_id) + setattr(event, propname, dlg.pop()) + except AttributeError: + pass + elif isinstance(prop, properties.TStringProperty): + dlg = TextInputDialog(self._activity, + text="Mandatory property", + field=propname) + setattr(event, propname, dlg.pop()) + else: + raise NotImplementedError() + + event_filters = self._state.get_event_filter_list() + if event_filters: + # linearize tutorial by inserting state + new_state = State(name=str(self._state_counter)) + self._state_counter += 1 + self._state.clear_event_filters() + for evt_filt, next_state in event_filters: + new_state.add_event_filter(evt_filt, next_state) + self._update_next_state(self._state, event, new_state.name) + next_state = new_state.name + # blocks are shifted, full redraw is necessary + self._overview.win.queue_draw() else: - addonname = type(action).__name__ - meta = addon.get_addon_meta(addonname) - had_introspect = False - for propname in meta['mandatory_props']: - prop = getattr(type(action), propname) - if isinstance(prop, properties.TUAMProperty): - had_introspect = True - self.introspecting = True - elif isinstance(prop, properties.TStringProperty): - dlg = TextInputDialog(text="Mandatory property", - field=propname) - setattr(action, propname, dlg.pop()) - elif isinstance(prop, properties.TIntProperty): - dlg = TextInputDialog(text="Mandatory property", - field=propname) - setattr(action, propname, int(dlg.pop())) - else: - raise NotImplementedError() - - # FIXME: hack to reuse previous introspection code - if not had_introspect: - self._tutorial.event(action) + # append empty state only if edit inserting at end of linearized + # tutorial. + self._update_next_state(self._state, event, str(self._state_counter)) + next_state = str(self._state_counter) + new_state = State(name=str(self._state_counter)) + self._state_counter += 1 + + self._state.add_event_filter(event, next_state) + self._tutorial.add_state(new_state) + self._overview.win.queue_draw() + self.set_insertion_point(new_state.name) def _action_refresh_cb(self, widget, evt, action): """ @@ -234,44 +322,54 @@ class Creator(object): "button-release-event", self._action_refresh_cb, action) self._propedit.action = action - def _add_step_cb(self, widget): - """Callback for the "add step" tool""" - self.introspecting = True + self._overview.win.queue_draw() def _cleanup_cb(self, *args): """ Quit editing and cleanup interface artifacts. """ - self.introspecting = False - eventfilter = filters.EventFilter() # undo actions so they don't persist through step editing - for action in self._tutorial.current_actions: + for action in self._state.get_action_list(): action.exit_editmode() - self._tutorial.event(eventfilter) - dlg = TextInputDialog(text=T("Enter a tutorial title."), - field=T("Title")) - tutorialName = "" - while not tutorialName: tutorialName = dlg.pop() - dlg.destroy() - - # prepare tutorial for serialization - tuto = Tutorial(tutorialName, self._tutorial.fsm) - bundle = bundler.TutorialBundler() - bundle.write_metadata_file(tuto) - bundle.write_fsm(self._tutorial.fsm) + dialog = gtk.MessageDialog( + parent=self._activity, + flags=gtk.DIALOG_MODAL, + type=gtk.MESSAGE_QUESTION, + buttons=gtk.BUTTONS_YES_NO, + message_format=T('Do you want to save before stopping edition?')) + do_save = dialog.run() + dialog.destroy() + if do_save == gtk.RESPONSE_YES: + self.save() # remove UI remains self._hlmask.covered = None self._activity._overlayer.remove(self._hlmask) - self._activity._overlayer.remove(self._state_bubble) self._hlmask.destroy() self._hlmask = None - self._tooldialog.destroy() self._propedit.destroy() + self._overview.destroy() self._activity.queue_draw() del self._activity._creator + def save(self, widget=None): + if not self.tuto: + dlg = TextInputDialog(self._activity, + text=T("Enter a tutorial title."), + field=T("Title")) + tutorialName = "" + while not tutorialName: tutorialName = dlg.pop() + dlg.destroy() + + # prepare tutorial for serialization + self.tuto = Tutorial(tutorialName, self._tutorial) + bundle = vault.TutorialBundler(self._guid) + self._guid = bundle.Guid + bundle.write_metadata_file(self.tuto) + bundle.write_fsm(self._tutorial) + + def launch(*args, **kwargs): """ Launch and attach a creator to the currently running activity. @@ -281,46 +379,59 @@ class Creator(object): activity._creator = Creator(activity) launch = staticmethod(launch) -class EditToolBox(gtk.Window): - """Helper toolbox class for managing action properties""" - def __init__(self, parent, action=None): - """ - Create the property edition toolbox and display it. +class ToolBox(object): + ICON_LABEL = 0 + ICON_IMAGE = 1 + ICON_NAME = 2 + ICON_TIP = 3 + def __init__(self, parent): + super(ToolBox, self).__init__() + self.__parent = parent + sugar_prefix = os.getenv("SUGAR_PREFIX",default="/usr") + glade_file = os.path.join(sugar_prefix, 'share', 'tutorius', + 'ui', 'creator.glade') + self.tree = gtk.glade.XML(glade_file) + self.window = self.tree.get_widget('mainwindow') + self._propbox = self.tree.get_widget('propbox') + + self.window.set_transient_for(parent) - @param parent the parent window of this toolbox, usually an activity - @param action the action to introspect/edit - """ - gtk.Window.__init__(self) self._action = None - self.__parent = parent # private avoid gtk clash - - self.set_title("Action Properties") - self.set_transient_for(parent) - self.set_decorated(True) - self.set_resizable(False) - self.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_UTILITY) - self.set_destroy_with_parent(True) - self.set_deletable(False) - self.set_size_request(200, 400) - - self._vbox = gtk.VBox() - self.add(self._vbox) - propwin = gtk.ScrolledWindow() - propwin.props.hscrollbar_policy = gtk.POLICY_AUTOMATIC - propwin.props.vscrollbar_policy = gtk.POLICY_AUTOMATIC - self._vbox.pack_start(propwin) - self._propbox = gtk.VBox(spacing=10) - propwin.add(self._propbox) - - self.action = action - - sw = gtk.gdk.screen_width() - sh = gtk.gdk.screen_height() - - self.show_all() - self.move(sw-10-200, (sh-400)/2) - - def refresh(self): + self.actions_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str) + self.actions_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING) + self.events_list = gtk.ListStore(str, gtk.gdk.Pixbuf, str, str) + self.events_list.set_sort_column_id(self.ICON_LABEL, gtk.SORT_ASCENDING) + + for toolname in addon.list_addons(): + meta = addon.get_addon_meta(toolname) + iconfile = gtk.Image() + iconfile.set_from_file(icon.get_icon_file_name(meta['icon'])) + img = iconfile.get_pixbuf() + label = format_multiline(meta['display_name']) + + if meta['type'] == addon.TYPE_ACTION: + self.actions_list.append((label, img, toolname, meta['display_name'])) + else: + self.events_list.append((label, img, toolname, meta['display_name'])) + + iconview_action = self.tree.get_widget('iconview1') + iconview_action.set_model(self.actions_list) + iconview_action.set_text_column(self.ICON_LABEL) + iconview_action.set_pixbuf_column(self.ICON_IMAGE) + iconview_action.set_tooltip_column(self.ICON_TIP) + iconview_event = self.tree.get_widget('iconview2') + iconview_event.set_model(self.events_list) + iconview_event.set_text_column(self.ICON_LABEL) + iconview_event.set_pixbuf_column(self.ICON_IMAGE) + iconview_event.set_tooltip_column(self.ICON_TIP) + + self.window.show() + + def destroy(self): + """ clean and free the toolbox """ + self.window.destroy() + + def refresh_properties(self): """Refresh property values from the selected action.""" if self._action is None: return @@ -333,6 +444,9 @@ class EditToolBox(gtk.Window): if isinstance(prop, properties.TStringProperty): propwdg = row.get_children()[1] propwdg.get_buffer().set_text(propval) + elif isinstance(prop, properties.TUAMProperty): + propwdg = row.get_children()[1] + propwdg.set_label(propval) elif isinstance(prop, properties.TIntProperty): propwdg = row.get_children()[1] propwdg.set_value(propval) @@ -348,12 +462,10 @@ class EditToolBox(gtk.Window): def set_action(self, action): """Setter for the action property.""" if self._action is action: - self.refresh() + self.refresh_properties() return - parent = self._propbox.get_parent() - parent.remove(self._propbox) - self._propbox = gtk.VBox(spacing=10) - parent.add(self._propbox) + for old_prop in self._propbox.get_children(): + self._propbox.remove(old_prop) self._action = action if action is None: @@ -368,6 +480,10 @@ class EditToolBox(gtk.Window): propwdg.get_buffer().set_text(propval) propwdg.connect_after("focus-out-event", \ self._str_prop_changed, action, propname) + elif isinstance(prop, properties.TUAMProperty): + propwdg = gtk.Button(propval) + propwdg.connect_after("clicked", \ + self._uam_prop_changed, action, propname) elif isinstance(prop, properties.TIntProperty): adjustment = gtk.Adjustment(value=propval, lower=prop.lower_limit.limit, @@ -388,8 +504,8 @@ class EditToolBox(gtk.Window): propwdg.set_text(str(propval)) row.pack_end(propwdg) self._propbox.pack_start(row, expand=False) - self._vbox.show_all() - self.refresh() + self._propbox.show_all() + self.refresh_properties() def get_action(self): """Getter for the action property""" @@ -406,6 +522,11 @@ class EditToolBox(gtk.Window): except ValueError: widget.set_text(str(getattr(action, propname)[idx])) self.__parent._creator._action_refresh_cb(None, None, action) + def _uam_prop_changed(self, widget, action, propname): + selector = WidgetSelector(self.__parent) + selection = selector.select() + setattr(action, propname, selection) + self.__parent._creator._action_refresh_cb(None, None, action) def _str_prop_changed(self, widget, evt, action, propname): buf = widget.get_buffer() setattr(action, propname, buf.get_text(buf.get_start_iter(), buf.get_end_iter())) @@ -414,9 +535,143 @@ class EditToolBox(gtk.Window): setattr(action, propname, widget.get_value_as_int()) self.__parent._creator._action_refresh_cb(None, None, action) + +class WidgetSelector(object): + """ + Allow selecting a widget from within a window without interrupting the + flow of the current call. + + The selector will run on the specified window until either a widget + is selected or abort() gets called. + """ + def __init__(self, window): + super(WidgetSelector, self).__init__() + self.window = window + self._intro_mask = None + self._intro_handle = None + self._select_handle = None + self._prelight = None + + def select(self): + """ + Starts selecting a widget, by grabbing control of the mouse and + highlighting hovered widgets until one is clicked. + @returns: a widget address or None + """ + if not self._intro_mask: + self._prelight = None + self._intro_mask = overlayer.Mask(catch_events=True) + self._select_handle = self._intro_mask.connect_after( + "button-press-event", self._end_introspect) + self._intro_handle = self._intro_mask.connect_after( + "motion-notify-event", self._intro_cb) + self.window._overlayer.put(self._intro_mask, 0, 0) + self.window._overlayer.queue_draw() + + while bool(self._intro_mask) and not gtk.main_iteration(): + pass + + return gtkutils.raddr_lookup(self._prelight) + + def _end_introspect(self, widget, evt): + if evt.type == gtk.gdk.BUTTON_PRESS and self._prelight: + self._intro_mask.catch_events = False + self._intro_mask.disconnect(self._intro_handle) + self._intro_handle = None + self._intro_mask.disconnect(self._select_handle) + self._select_handle = None + self.window._overlayer.remove(self._intro_mask) + self._intro_mask = None + # for some reason, gtk may not redraw after this unless told to. + self.window.queue_draw() + + def _intro_cb(self, widget, evt): + """ + Callback for capture of widget events, when in introspect mode. + """ + # widget has focus, let's hilight it + win = gtk.gdk.display_get_default().get_window_at_pointer() + if not win: + return + click_wdg = win[0].get_user_data() + if not click_wdg.is_ancestor(self.window._overlayer): + # as popups are not (yet) supported, it would break + # badly if we were to play with a widget not in the + # hierarchy. + return + for hole in self._intro_mask.pass_thru: + self._intro_mask.mask(hole) + self._intro_mask.unmask(click_wdg) + self._prelight = click_wdg + + self.window.queue_draw() + + def abort(self): + """ + Ends the selection. The control will return to the select() caller + with a return value of None, as selection was aborted. + """ + self._intro_mask.catch_events = False + self._intro_mask.disconnect(self._intro_handle) + self._intro_handle = None + self._intro_mask.disconnect(self._select_handle) + self._select_handle = None + self.window._overlayer.remove(self._intro_mask) + self._intro_mask = None + self._prelight = None + +class SignalInputDialog(gtk.MessageDialog): + def __init__(self, parent, text, field, addr): + """ + Create a gtk signal selection dialog. + + @param parent: the parent window this dialog should stay over. + @param text: the title of the dialog. + @param field: the field description of the dialog. + @param addr: the widget address from which to fetch signal list. + """ + gtk.MessageDialog.__init__(self, parent, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + gtk.MESSAGE_QUESTION, + gtk.BUTTONS_OK, + None) + self.set_markup(text) + self.model = gtk.ListStore(str) + widget = gtkutils.find_widget(parent, addr) + for signal_name in gobject.signal_list_names(widget): + self.model.append(row=(signal_name,)) + self.entry = gtk.ComboBox(self.model) + cell = gtk.CellRendererText() + self.entry.pack_start(cell) + self.entry.add_attribute(cell, 'text', 0) + hbox = gtk.HBox() + lbl = gtk.Label(field) + hbox.pack_start(lbl, False) + hbox.pack_end(self.entry) + self.vbox.pack_end(hbox, True, True) + self.show_all() + + def pop(self): + """ + Show the dialog. It will run in it's own loop and return control + to the caller when a signal has been selected. + + @returns: a signal name or None if no signal was selected + """ + self.run() + self.hide() + iter = self.entry.get_active_iter() + if iter: + text = self.model.get_value(iter, 0) + return text + return None + + def _dialog_done_cb(self, entry, response): + self.response(response) + class TextInputDialog(gtk.MessageDialog): - def __init__(self, text, field): - gtk.MessageDialog.__init__(self, None, + def __init__(self, parent, text, field): + gtk.MessageDialog.__init__(self, parent, gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_QUESTION, gtk.BUTTONS_OK, @@ -440,4 +695,39 @@ class TextInputDialog(gtk.MessageDialog): def _dialog_done_cb(self, entry, response): self.response(response) +# The purpose of this function is to reformat text, as current IconView +# implentation does not insert carriage returns on long lines. +# To preserve layout, this call reformat text to fit in small space under an +# icon. +def format_multiline(text, length=10, lines=3, line_separator='\n'): + """ + Reformat a text to fit in a small space. + + @param length: maximum char per line + @param lines: maximum number of lines + """ + words = text.split(' ') + line = list() + return_val = [] + linelen = 0 + + for word in words: + t_len = linelen+len(word) + if t_len < length: + line.append(word) + linelen = t_len+1 # count space + else: + if len(return_val)+1 < lines: + return_val.append(' '.join(line)) + line = list() + linelen = 0 + line.append(word) + else: + return_val.append(' '.join(line+['...'])) + return line_separator.join(return_val) + + return_val.append(' '.join(line)) + return line_separator.join(return_val) + + # vim:set ts=4 sts=4 sw=4 et: diff --git a/tutorius/dbustools.py b/tutorius/dbustools.py index 1b685d7..5d70d7b 100644 --- a/tutorius/dbustools.py +++ b/tutorius/dbustools.py @@ -25,6 +25,7 @@ def remote_call(callable, args, return_cb=None, error_cb=None, block=False): except Exception, e: #Use the specified error handler even for blocking calls errhandler_cb(e) + return #Return value signature might be : if ret_val is None: diff --git a/tutorius/editor.py b/tutorius/editor.py index 42cc718..9d2effe 100644 --- a/tutorius/editor.py +++ b/tutorius/editor.py @@ -24,7 +24,7 @@ import gobject from gettext import gettext as _ -from sugar.tutorius.gtkutils import register_signals_numbered, get_children +from .gtkutils import register_signals_numbered, get_children class WidgetIdentifier(gtk.Window): """ diff --git a/tutorius/engine.py b/tutorius/engine.py index dda9f3f..e77a018 100644 --- a/tutorius/engine.py +++ b/tutorius/engine.py @@ -1,10 +1,10 @@ import logging import dbus.mainloop.glib from jarabe.model import shell - -from sugar.tutorius.bundler import TutorialStore from sugar.bundle.activitybundle import ActivityBundle +from .vault import Vault + class Engine: """ Driver for the execution of tutorials @@ -25,11 +25,9 @@ class Engine: self._tutorial.detach() self._tutorial = None - store = TutorialStore() - #Get the active activity from the shell activity = self._shell.get_active_activity() - self._tutorial = store.load_tutorial(tutorialID, bundle_path=activity.get_bundle_path()) + self._tutorial = Vault.loadTutorial(tutorialID) #TProbes automatically use the bundle id, available from the ActivityBundle bundle = ActivityBundle(activity.get_bundle_path()) diff --git a/tutorius/filters.py b/tutorius/filters.py index 44621d5..38cf86b 100644 --- a/tutorius/filters.py +++ b/tutorius/filters.py @@ -18,7 +18,7 @@ import logging logger = logging.getLogger("filters") -from sugar.tutorius import properties +from . import properties class EventFilter(properties.TPropContainer): diff --git a/tutorius/linear_creator.py b/tutorius/linear_creator.py index 78e94ce..f664c49 100644 --- a/tutorius/linear_creator.py +++ b/tutorius/linear_creator.py @@ -15,12 +15,12 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -from sugar.tutorius.core import * -from sugar.tutorius.actions import * -from sugar.tutorius.filters import * - from copy import deepcopy +from .core import * +from .actions import * +from .filters import * + class LinearCreator(object): """ This class is used to create a FSM from a linear sequence of orders. The diff --git a/tutorius/overlayer.py b/tutorius/overlayer.py index 0a3d542..b967739 100644 --- a/tutorius/overlayer.py +++ b/tutorius/overlayer.py @@ -58,7 +58,7 @@ class Overlayer(gtk.Layout): @param overlayed widget to be overlayed. Will be resized to full size. """ def __init__(self, overlayed=None): - gtk.Layout.__init__(self) + super(Overlayer, self).__init__() self._overlayed = overlayed if overlayed: @@ -83,7 +83,7 @@ class Overlayer(gtk.Layout): if hasattr(child, "draw_with_context"): # if the widget has the CanvasDrawable protocol, use it. child.no_expose = True - gtk.Layout.put(self, child, x, y) + super(Overlayer, self).put(child, x, y) # be sure to redraw or the overlay may not show self.queue_draw() diff --git a/tutorius/properties.py b/tutorius/properties.py index 78e3c2b..5422532 100644 --- a/tutorius/properties.py +++ b/tutorius/properties.py @@ -19,12 +19,12 @@ TutoriusProperties have the same behaviour as python properties (assuming you also use the TPropContainer), with the added benefit of having builtin dialog prompts and constraint validation. """ +from copy import copy, deepcopy -from sugar.tutorius.constraints import Constraint, \ +from .constraints import Constraint, \ UpperLimitConstraint, LowerLimitConstraint, \ MaxSizeConstraint, MinSizeConstraint, \ ColorConstraint, FileConstraint, BooleanConstraint, EnumConstraint -from copy import copy class TPropContainer(object): """ @@ -95,6 +95,12 @@ class TPropContainer(object): """ return object.__getattribute__(self, "_props").keys() + def get_properties_dict_copy(self): + """ + Return a deep copy of the dictionary of properties from that object. + """ + return deepcopy(self._props) + # Providing the hash methods necessary to use TPropContainers # in a dictionary, according to their properties def __hash__(self): @@ -104,7 +110,7 @@ class TPropContainer(object): return hash(tuple(map(tuple,sorted(self._props.items(), cmp=lambda x, y: cmp(x[0], y[0]))))) def __eq__(self, e2): - return self._props == e2._props + return isinstance(e2, type(self)) and self._props == e2._props # Adding methods for pickling and unpickling an object with # properties @@ -310,6 +316,8 @@ class TUAMProperty(TutoriusProperty): self.type = "uam" + self.default = self.validate(value) + class TAddonProperty(TutoriusProperty): """ Reprensents an embedded tutorius Addon Component (action, trigger, etc.) @@ -331,6 +339,16 @@ class TAddonProperty(TutoriusProperty): return super(TAddonProperty, self).validate(value) raise ValueError("Expected TPropContainer instance as TaddonProperty value") +class TEventType(TutoriusProperty): + """ + Represents an GUI signal for a widget. + """ + def __init__(self, value): + super(TEventType, self).__init__() + self.type = "gtk-signal" + + self.default = self.validate(value) + class TAddonListProperty(TutoriusProperty): """ Reprensents an embedded tutorius Addon List Component. diff --git a/tutorius/service.py b/tutorius/service.py index 21f0cf1..eb246a1 100644 --- a/tutorius/service.py +++ b/tutorius/service.py @@ -1,7 +1,7 @@ -from engine import Engine import dbus -from dbustools import remote_call +from .engine import Engine +from .dbustools import remote_call _DBUS_SERVICE = "org.tutorius.Service" _DBUS_PATH = "/org/tutorius/Service" diff --git a/tutorius/store.py b/tutorius/store.py index 480c81b..81925ed 100644 --- a/tutorius/store.py +++ b/tutorius/store.py @@ -15,6 +15,10 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA import urllib +import urllib2 +from xml.dom import minidom +from apilib.restful_lib import Connection +from array import array class StoreProxy(object): """ @@ -23,78 +27,232 @@ class StoreProxy(object): shop to implement all the requests that could be made to the Store. """ + def __init__(self, base_url): + + # Base Urls for the api + self.base_url = base_url + self.remora_api = "api/1.4" + self.tutorius_api = "TutoriusApi" + self.bandwagon_api = "api/1.4/sharing" + + self.api_auth_key = None + + # Prepares the connection with the api + self.conn = Connection(self.base_url) + + # Setup the helper + self.helper = StoreProxyHelper() + def get_categories(self): """ Returns all the categories registered in the store. Categories are used to classify tutorials according to a theme. (e.g. Mathematics, History, etc...) - + @return The list of category names stored on the server. """ - raise NotImplementedError("get_categories() not implemented") - def get_tutorials(self, keywords=None, category=None, startIndex=0, numResults=10, sortBy='name'): + request_url = "/%s/categories" % (self.tutorius_api) + + response = self.conn.request_get(request_url) + + if self.helper.iserror(response): + return None + + xml_response = minidom.parseString(response['body']) + + xml_categories = xml_response.getElementsByTagName('category') + + categories = list() + + # Loop through the categories and create the list to be returned + for xml_category in xml_categories: + category = {} + + category['id'] = xml_category.getElementsByTagName('id')[0].firstChild.nodeValue + category['name'] = xml_category.getElementsByTagName('name')[0].firstChild.nodeValue + + categories.append(category) + + return categories + + def search(self, keywords, category='all', page=1, numResults=10, sortBy='name'): """ - Returns the list of tutorials that correspond to the given search criteria. + Returns a list of tutorials that correspond to the given search criteria. + + @param keywords The keywords to search for + @param page The page in the result set from which to return results. This is + used to allow applications to fetch results one set at a time. + @param numResults The max number of results that can be returned in a page + @param sortBy The field on which to sort the results + @return A list of tutorial meta-data that corresponds to the query + """ + request_url = "/%s/search/%s/%s/%d/%d/%s" % (self.tutorius_api, keywords, category, page, numResults, sortBy) + + response = self.conn.request_get(request_url) + + if (self.helper.iserror(response)): + return None + + xml_response = minidom.parseString(response['body']) + + xml_tutorials = xml_response.getElementsByTagName('tutorial') + + tutorials = list() + + for xml_tutorial in xml_tutorials: + tutorial = self.helper.parse_tutorial(xml_tutorial) + tutorials.append(tutorial) - @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. + return tutorials + + def get_tutorials(self, category='all', page=1, numResults=10, sortBy='name'): + """ + Returns the list of tutorials that correspond to the given search criteria. + @param category The category in which to restrict the search. - @param startIndex The index in the result set from which to return results. This is + @param page The page in the result set from which to return results. This is used to allow applications to fetch results one set at a time. - @param numResults The max number of results that can be returned + @param numResults The max number of results that can be returned in a page @param sortBy The field on which to sort the results @return A list of tutorial meta-data that corresponds to the query """ - raise NotImplementedError("get_tutorials() not implemented") + + request_url = "/%s/tutorials/%s/%d/%d/%s" % (self.tutorius_api, category, page, numResults, sortBy) + + response = self.conn.request_get(request_url) + + if (self.helper.iserror(response)): + return None + + xml_response = minidom.parseString(response['body']) + + xml_tutorials = xml_response.getElementsByTagName('tutorial') - def get_tutorial_collection(self, collection_name): + tutorials = list() + + for xml_tutorial in xml_tutorials: + tutorial = self.helper.parse_tutorial(xml_tutorial) + tutorials.append(tutorial) + + return tutorials + + def list(self, type='recommended', numResults=3): """ - Returns a list of tutorials corresponding to the given collection name. - Collections can be groups like '5 most downloaded' or 'Top 10 ratings'. + Returns a list of tutorials corresponding to the type specified. + Type examples: 'Most downloaded', 'recommended', etc. - @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 + @param type The type of list (Most downloaded, recommended, etc.) + @return A list of tutorials """ - raise NotImplementedError("get_tutorial_collection() not implemented... yet!") + request_url = "/%s/list/%s/tutorial/%s" % (self.remora_api, type, numResults) + + response = self.conn.request_get(request_url) + + if (self.helper.iserror(response)): + return None + + xml_response = minidom.parseString(response['body']) + + xml_tutorials = xml_response.getElementsByTagName('addon') + + tutorials = list() + + for xml_tutorial in xml_tutorials: + tutorial = self.helper.parse_tutorial(xml_tutorial) + tutorials.append(tutorial) + return tutorials + + def get_latest_version(self, tutorial_id_list): """ Returns the latest version number on the server, for each tutorial ID in the list. - + @param tutorial_id_list The list of tutorial IDs from which we want to known the latest version number. - @return A dictionary having the tutorial ID as the key and the version + @return A dictionary having the tutorial ID as the key and the version as the value. """ - raise NotImplementedError("get_latest_version() not implemented") - + + versions = {} + + for tutorial_id in tutorial_id_list: + + request_url = "/%s/addon/%s/" % (self.remora_api, tutorial_id) + + response = self.conn.request_get(request_url) + + if (self.helper.iserror(response)): + return None + + xml = minidom.parseString(response['body']) + + versionnode = xml.getElementsByTagName("version")[0] + + version = versionnode.firstChild.nodeValue + + versions[tutorial_id] = version + + return versions + def download_tutorial(self, tutorial_id, version=None): """ - Fetches the tutorial file from the server and returns the - + 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") + request_url = "/%s/addon/%s/" % (self.remora_api, tutorial_id) + + response = self.conn.request_get(request_url) + if (self.helper.iserror(response)): + return None + + xml = minidom.parseString(response['body']) + + installnode = xml.getElementsByTagName("install")[0] + installurl = installnode.firstChild.nodeValue + + fp = urllib.urlopen(installurl) + + return fp + def login(self, username, password): """ Logs in the user on the store and saves the login status in the proxy state. After a successful logon, the operation requiring a login will be successful. - + + @param username + @param password @return True if the login was successful, False otherwise """ - raise NotImplementedError("login() not implemented yet") + request_url = "/%s/auth/" % (self.tutorius_api) + + params = {'username': username, 'password': password} + + response = self.conn.request_post(request_url, params) + if (self.helper.iserror(response)): + return False + + xml_response = minidom.parseString(response['body']) + + keynode = xml_response.getElementsByTagName("token")[0] + + key = keynode.getAttribute('value') + + self.api_auth_key = key + + return True + def close_session(self): """ Ends the user's session on the server and changes the state of the proxy @@ -102,72 +260,214 @@ class StoreProxy(object): @return True if the user was disconnected, False otherwise """ - raise NotImplementedError("close_session() not implemented yet") + request_url = "/%s/auth/%s" % (self.tutorius_api, self.api_auth_key) + + headers = { 'X-API-Auth' : self.api_auth_key } + + response = self.conn.request_delete(request_url, None, headers) + if (self.helper.iserror(response)): + return False + + self.api_auth_key = None + + return True + def get_session_id(self): """ Gives the current session ID cached in the Store Proxy, or returns None is the user is not logged yet. - + @return The current session's ID, or None if the user is not logged """ - raise NotImplementedError("get_session_id() not implemented yet") - + return self.api_auth_key + def rate(self, value, tutorial_store_id): """ Sends a rating for the given tutorial. - + This function requires the user to be logged in. - - @param value The value of the rating. It must be an integer with a value + + @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") + request_url = "/%s/review/%s" % (self.tutorius_api, tutorial_store_id) + + params = {'title': 'from api', 'body': 'from api', 'rating': value} + headers = { 'X-API-Auth' : self.api_auth_key } + + response = self.conn.request_post(request_url, params, None, None, headers) - def publish(self, tutorial): + if self.helper.iserror(response): + return False + + return True + + def publish(self, tutorial, tutorial_info=None, tutorial_store_id = None): """ Sends a tutorial to the store. - + This function requires the user to be logged in. - @param tutorial The tutorial file to be sent. Note that this is the + @param tutorial The tutorial file to be sent. Note that this is the content itself and not the path to the file. + @param tutorial_info An array containing the tutorial information @return True if the tutorial was sent correctly, False otherwise. """ - raise NotImplemetedError("publish() not implemented") + + # This is in the case we have to re-publish a tutorial + if tutorial_store_id is not None: + request_url = "/%s/publish/%s" % (self.tutorius_api, tutorial_store_id) + headers = { 'X-API-Auth' : self.api_auth_key } + + response = self.conn.request_post(request_url, None, None, None, headers) + + if self.helper.iserror(response): + return False + + return True + + # Otherwise, we want to publish a new tutorial + if tutorial_info == None: + return False + + request_url = "/%s/publish/" % (self.tutorius_api) + + headers = { 'X-API-Auth' : self.api_auth_key } + + response = self.conn.request_post(request_url, tutorial_info, tutorial, tutorial_info['filename'], headers) + + if self.helper.iserror(response): + return False + + return True + def unpublish(self, tutorial_store_id): """ Removes a tutorial from the server. The user in the current session - needs to be the creator for it to be unpublished. This will remove + 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") + request_url = "/%s/publish/%s" % (self.tutorius_api, tutorial_store_id) + + headers = { 'X-API-Auth' : self.api_auth_key } + response = self.conn.request_delete(request_url, None, headers) - def update_published_tutorial(self, tutorial_id, tutorial): + if self.helper.iserror(response): + return False + + return True + + def update_published_tutorial(self, tutorial_id, tutorial, tutorial_info): """ Sends the new content for the tutorial with the given ID. - + This function requires the user to be logged in. - + @param tutorial_id The ID of the tutorial to be updated @param tutorial The bundled tutorial file content (not a path!) @return True if the tutorial was sent and updated, False otherwise """ - raise NotImplementedError("update_published_tutorial() not implemented yet") + request_url = "/%s/update/%s" % (self.tutorius_api, tutorial_id) + + headers = { 'X-API-Auth' : self.api_auth_key } + response = self.conn.request_post(request_url, tutorial_info, tutorial, tutorial_info['filename'], headers) + + if self.helper.iserror(response): + return False + + return True + + def register_new_user(self, user_info): """ - Creates a new user from the given user information. + 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") + request_url = "/%s/registerNewUser" % (self.tutorius_api) + + params = {'nickname': user_info['nickname'], 'password': user_info['password'], 'email': user_info['email']} + + response = self.conn.request_post(request_url, params) + + if self.helper.iserror(response): + return False + + return True + + +class StoreProxyHelper(object): + """ + Implements helper methods for the Store, more specifically + methods to handle xml responses and errors + """ + def iserror(self, response): + """ + Check if the response received from the server is an error + + @param response The XML response from the server + @return True if the response is an error + """ + + # first look for HTTP errors + http_status = response['headers']['status'] + + if http_status in ['400', '401', '403', '500' ]: + return True + + # Now check if the response is valid XML + try: + minidom.parseString(response['body']) + except Exception, e: + return True + + # The response is valid XML, parse it and look for + # an error in xml format + xml_response = minidom.parseString(response['body']) + + errors = xml_response.getElementsByTagName('error') + + if (len(errors) > 0): + return True + + return False + + def parse_tutorial(self, xml_tutorial): + """ + Parse a tutorial's XML metadata and returns a dictionnary + containing the metadata + + @param xml_tutorial The tutorial metadata in XML format + @return A dictionnary containing the metadata + """ + tutorial = {} + + params = [ + 'name', + 'summary', + 'version', + 'description', + 'author', + 'rating' + ] + + for param in params: + xml_node = xml_tutorial.getElementsByTagName(param)[0].firstChild + + if xml_node != None: + tutorial[param] = xml_node.nodeValue + else: + tutorial[param] = '' + + return tutorial diff --git a/tutorius/tutorial.py b/tutorius/tutorial.py new file mode 100644 index 0000000..9831a7b --- /dev/null +++ b/tutorius/tutorial.py @@ -0,0 +1,806 @@ +# Copyright (C) 2009, Tutorius.org +# Copyright (C) 2009, Erick Lavoie +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +#TODO: For notification of modifications on the Tutorial check for GObject and PyDispatcher for inspiration + +from .constraints import ConstraintException +from .properties import TPropContainer + +_NAME_SEPARATOR = "/" + +class Tutorial(object): + """ This class replaces the previous Tutorial class and + allows manipulation of the abstract representation + of a tutorial as a state machine + """ + + INIT = "INIT" + END = "END" + INITIAL_TRANSITION_NAME = INIT + "/transition0" + + + def __init__(self, name, state_dict=None): + """ + The constructor for the Tutorial. By default, the tutorial contains + only an initial state and an end state. + The initial state doesn't contain any action but it contains + a single automatic transition + between the initial state and the end state + . + + The end state doesn't contain any action nor transition. + + If state_dict is provided, a valid initial state and an end state + must be provided. + + @param name The name of the tutorial + @param state_dict optional, a valid dictionary of states + @raise InvalidStateDictionary + """ + self.name = name + + + # We will use an adjacency list representation through the + # usage of state objects because our graph representation + # is really sparse and mostly linear, for a brief + # example of graph programming in python see: + # http://www.python.org/doc/essays/graphs + if not state_dict: + self._state_dict = \ + {Tutorial.INIT:State(name=Tutorial.INIT),\ + Tutorial.END:State(name=Tutorial.END)} + + self.add_transition(Tutorial.INIT, \ + (AutomaticTransitionEvent(), Tutorial.END)) + else: + raise NotImplementedError("Tutorial: Initilization from a dictionary is not supported yet") + + + # Minimally check for the presence of an INIT and an END + # state + if not self._state_dict.has_key(Tutorial.INIT): + raise Exception("No INIT state found in state_dict") + + if not self._state_dict.has_key(Tutorial.END): + raise Exception("No END state found in state_dict") + + # TODO: Validate once validation is working + #self.validate() + + # Initialize variables for generating unique names + # TODO: We should take the max number from the + # existing state names + self._state_name_nb = 0 + + + def add_state(self, action_list=(), transition_list=()): + """ + Add a new state to the state machine. The state is + initialized with the action list and transition list + and a new unique name is returned for this state. + + The actions are added using add_action. + + The transitions are added using add_transition. + + @param action_list The list of valid actions for this state + @param transition_list The list of valid transitions + @return unique name for this state + """ + name = self._generate_unique_state_name() + + for action in action_list: + self._validate_action(action) + + for transition in transition_list: + self._validate_transition(transition) + + state = State(name, action_list, transition_list) + + self._state_dict[name] = state + + return name + + + def add_action(self, state_name, action): + """ + Add an action to a specific state. A name unique throughout the + tutorial is generated to refer precisely to this action + and is returned. + + The action is validated. + + @param state_name The name of the state to add an action to + @param action The action to be added + @return unique name for this action + @raise LookupError if state_name doesn't exist + """ + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + + self._validate_action(action) + + return self._state_dict[state_name].add_action(action) + + def add_transition(self, state_name, transition): + """ + Add a transition to a specific state. A name unique throughout the + tutorial is generated to refer precisely to this transition + and is returned. Inserting a duplicate transition will raise + an exception. + + The transition is validated. + + @param state_name The name of the state to add a transition to + @param transition The transition to be added + @return unique name for this action + @raise LookupError if state_name doesn't exist + @raise TransitionAlreadyExists + """ + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + + self._validate_transition(transition) + + # The unicity of the transition is validated by the state + return self._state_dict[state_name].add_transition(transition) + + def update_action(self, action_name, new_properties): + """ + Update the action with action_name with a property dictionary + new_properties. If one property update is invalid, the old + values are restored and an exception is raised. + + @param action_name The name of the action to update + @param new_properties The properties that will update the action + @return old properties from the action + @raise LookupError if action_name doesn't exist + @raise ConstraintException if a property constraint is violated + """ + state_name = self._validate_state_name(action_name) + + #TODO: We should validate that only properties defined on the action + # are passed in + + return self._state_dict[state_name].update_action(action_name, new_properties) + + def update_transition(self, transition_name, new_properties=None, new_state=None): + """ + Update the transition with transition_name with new properties and/or + a new state to transition to. A None value means that the corresponding + value won't be updated. If one property update is invalid, the old + values are restored and an exception is raised. + + @param transition_name The name of the transition to replace + @param new_properties The properties that will update the transition + @param new_state The new state to transition to + @return a tuple (old_properties, old_state) with previous values + @raise LookupError if transition_name doesn't exist + @raise ConstraintException if a property constraint is violated + """ + state_name = self._validate_state_name(transition_name) + + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: transition <" + transition_name +\ + "> is not defined") + + if new_state and not self._state_dict.has_key(new_state): + raise LookupError("Tutorial: destination state <" + new_state +\ + "> is not defined") + + #TODO: We should validate that only properties defined on the action + # are passed in + + return self._state_dict[state_name].update_transition(transition_name, new_properties, new_state) + + def delete_action(self, action_name): + """ + Delete the action identified by action_name. + + @param action_name The name of the action to be deleted + @return the action that has been deleted + @raise LookupError if transition_name doesn't exist + """ + state_name = self._validate_state_name(action_name) + + return self._state_dict[state_name].delete_action(action_name) + + def delete_transition(self, transition_name): + """ + Delete the transition identified by transition_name. + + @param transition_name The name of the transition to be deleted + @return the transition that has been deleted + @raise LookupError if transition_name doesn't exist + """ + state_name = self._validate_state_name(transition_name) + + return self._state_dict[state_name].delete_transition(transition_name) + + def delete_state(self, state_name): + """ + Delete the state, delete all the actions and transitions + in this state, update the transitions from the state that + pointed to this one to point to the next state and remove all the + unreachable states recursively. + + All but the INIT and END states can be deleted. + + @param state_name The name of the state to remove + @return The deleted state + @raise StateDeletionError when trying to delete the INIT or the END state + @raise LookupError if state_name doesn't exist + """ + self._validate_state_name(state_name) + + if state_name == Tutorial.INIT or state_name == Tutorial.END: + raise StateDeletionError("<" + state_name + "> cannot be deleted") + + next_states = set(self.get_following_states_dict(state_name).values()) + previous_states = set(self.get_previous_states_dict(state_name).values()) + + # For now tutorials should be completely linear, + # let's make sure they are + assert len(next_states) <= 1 and len(previous_states) <= 1 + + # Update transitions only if they existed + if len(next_states) == 1 and len(previous_states) == 1: + next_state = next_states.pop() + previous_state = previous_states.pop() + + transitions = previous_state.get_transition_dict() + for transition_name, (event, state_to_delete) in \ + transitions.iteritems(): + self.update_transition(transition_name, None, next_state.name) + + # Since we assume tutorials are linear for now, we do not need + # to search for unreachable states + + return self._state_dict.pop(state_name) + + + + def get_action_dict(self, state_name=None): + """ + Returns a reference to the dictionary of all actions for a specific + state. + If no state_name is provided, returns an action dictionary + containing actions for all states. + + @param state_name The name of the state to list actions from + @return A dictionary of actions with action_name as key and action + as value for state_name + @raise LookupError if state_name doesn't exist + """ + if state_name and not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + elif state_name: + return self._state_dict[state_name].get_action_dict() + else: + action_dict = {} + for state in self._state_dict.itervalues(): + action_dict.update(state.get_action_dict()) + return action_dict + + def get_transition_dict(self, state_name=None): + """ + Returns a dictionary of all actions for a specific state. + If no state_name is provided, returns an action dictionary + containing actions for all states. + + @param state_name The name of the state to list actions from + @return A dictionary of transitions with transition_name as key and transition as value for state_name + @raise LookupError if state_name doesn't exist + """ + if state_name and not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + elif state_name: + return self._state_dict[state_name].get_transition_dict() + else: + transition_dict = {} + for state in self._state_dict.itervalues(): + transition_dict.update(state.get_transition_dict()) + return transition_dict + + + def get_state_dict(self): + """ + Returns a reference to the internal state dictionary used by + the Tutorial. + + @return A reference to the dictionary of all the states in the tutorial with state_name as key and state as value + """ + # Maybe we will need to change it for an immutable dictionary + # to make sure the internal representation is not modified + return self._state_dict + + def get_following_states_dict(self, state_name): + """ + Returns a dictionary of the states that are immediately reachable from + a specific state. + + @param state_name The name of the state + @raise LookupError if state_name doesn't exist + """ + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + + following_states_dict = {} + for (event, next_state) in \ + self._state_dict[state_name].get_transition_dict().itervalues(): + following_states_dict[next_state] = self._state_dict[next_state] + + return following_states_dict + + def get_previous_states_dict(self, state_name): + """ + Returns a dictionary of the states that can transition to a + specific state. + + @param state_name The name of the state + @raise LookupError if state_name doesn't exist + """ + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + state_name +\ + "> is not defined") + + + previous_states_dict = {} + for iter_state_name, state in \ + self._state_dict.iteritems(): + + for (event, next_state) in \ + self._state_dict[iter_state_name].get_transition_dict().itervalues(): + + if next_state != state_name: + continue + + previous_states_dict[iter_state_name] = state + # if we have found one, do not look for other transitions + # from this state + break + + return previous_states_dict + + # Convenience methods for common tutorial manipulations + def add_state_before(self, state_name, action_list=[], event_list=[]): + """ + Add a new state just before another state state_name. All transitions + going to state_name are updated to end on the new state and all + events will be converted to transitions ending on state_name. + + When event_list is empty, an automatic transition to state_name + will be added to maintain consistency. + + @param state_name The name of the state that will be preceded by the + new state + @param action_list The list of valid actions for this state + @param event_list The list of events that will be converted to transitions to state_name + @return unique name for this state + @raise LookupError if state_name doesn't exist + """ + raise NotImplementedError + + # Callback mecanism to allow automatic change notification when + # the tutorial is modified + def register_action_added_cb(self, cb): + """ + Register a function cb that will be called when any action from + the tutorial is added. + + cb should be of the form: + + cb(action_name, new_action) where: + action_name is the unique name of the action that was added + new_action is the new action + + @param cb The callback function to be called + @raise InvalidCallbackFunction if the callback has less or more than + 2 arguments + """ + raise NotImplementedError + + def register_action_updated_cb(self, cb): + """ + Register a function cb that will be called when any action from + the tutorial is updated. + + cb should be of the form: + + cb(action_name, new_action) where: + action_name is the unique name of the action that has changed + new_action is the new action that replaces the old one + + @param cb The callback function to be called + @raise InvalidCallbackFunction if the callback has less or more than + 2 arguments + """ + raise NotImplementedError + + def register_action_deleted_cb(self, cb): + """ + Register a function cb that will be called when any action from + the tutorial is deleted. + + cb should be of the form: + + cb(action_name, old_action) where: + action_name is the unique name of the action that was deleted + old_action is the new action that replaces the old one + + @param cb The callback function to be called + @raise InvalidCallbackFunction if the callback has less or more than + 2 arguments + """ + raise NotImplementedError + + def register_transition_updated_cb(self, cb): + """ + Register a function cb that will be called when any transition from + the tutorial is updated. + + cb should be of the form: + + cb(transition_name, new_transition) where: + transition_name is the unique name of the transition + that has changed + new_transition is the new transition that replaces the old one + + @param cb The callback function to be called + @raise InvalidCallbackFunction if the callback has less or more than + 2 arguments + """ + raise NotImplementedError + + # Validation to assert precondition + def _validate_action(self, action): + """ + Validate that an action conforms to what we expect, + throws an exception otherwise. + + @param action The action to validate + @except InvalidAction if the action fails to conform to what we expect + """ + pass + + def _validate_transition(self, transition): + """ + Validate that a transition conforms to what we expect, + throws an exception otherwise. + + @param transition The transition to validate + @except InvalidTransition if the transition fails to conform to what we expect + """ + pass + + # Validation decorators to assert preconditions + def _validate_state_name(self,name): + """ + Assert that the state name found in the first part of the string + actually exists + + @param name The name that starts with a state name + @return the state_name from name + @raise LookupError if state_name doesn't exist + """ + state_name = name + + if name.find(_NAME_SEPARATOR) != -1: + state_name = name[:name.find(_NAME_SEPARATOR)] + + if not self._state_dict.has_key(state_name): + raise LookupError("Tutorial: state <" + str(state_name) +\ + "> is not defined") + + return state_name + + def validate(self): + """ + Validate the state machine for a serie of properties: + 1. No unreachable states + 2. No dead end state (except END) + 3. No branching in the main path + 4. No loop in the main path + 5. ... + + Throw an exception for the first condition that is not met. + """ + raise NotImplementedError + + def _generate_unique_state_name(self): + name = "State" + str(self._state_name_nb) + self._state_name_nb += 1 + return name + + def __str__(self): + """ + Return a string representation of the tutorial + """ + return str(self._state_dict) + + +class State(object): + """ + This is a step in a tutorial. The state represents a collection of actions + to undertake when entering the state, and a series of transitions to lead + to next states. + + This class is not meant to be used explicitly as no validation is done on + inputs, the validation should be done by the containing class. + """ + + def __init__(self, name, action_list=(), transition_list=()): + """ + Initializes the content of the state, such as loading the actions + that are required and building the correct transitions. + + @param action_list The list of actions to execute when entering this + state + @param transition_list A list of tuples of the form + (event, next_state_name), that explains the outgoing links for + this state + """ + object.__init__(self) + + self.name = name + + # Initialize internal variables for name generation + self.action_name_nb = 0 + self.transition_name_nb = 0 + + self._actions = {} + for action in action_list: + self.add_action(action) + + self._transitions = {} + for transition in transition_list: + self.add_transition(transition) + + + # Action manipulations + def add_action(self, new_action): + """ + Adds an action to the state + + @param new_action The action to add + @return a unique name for this action + """ + action_name = self._generate_unique_action_name(new_action) + self._actions[action_name] = new_action + return action_name + + def delete_action(self, action_name): + """ + Delete the action with the name action_name + + @param action_name The name of the action to delete + @return The action deleted + @raise LookupError if action_name doesn't exist + """ + if self._actions.has_key(action_name): + return self._actions.pop(action_name) + else: + raise LookupError("Tutorial.State: action <" + action_name + "> is not defined") + + def update_action(self, action_name, new_properties): + """ + Update the action with action_name with a property dictionary + new_properties. If one property update is invalid, the old + values are restored and an exception is raised. + + @param action_name The name of the action to update + @param new_properties The properties that will update the action + @return The old properties from the action + @raise LookupError if action_name doesn't exist + @raise ConstraintException if a property constraint is violated + """ + if not self._actions.has_key(action_name): + raise LookupError("Tutorial.State: action <" + action_name + "> is not defined") + + action = self._actions[action_name] + old_properties = action.get_properties_dict_copy() + try: + for property_name, property_value in new_properties.iteritems(): + action.__setattr__(property_name, property_value) + return old_properties + except ConstraintException, e: + action._props = old_properties + raise e + + def get_action_dict(self): + """ + Return the reference to the internal action dictionary. + + @return A dictionary of actions that the state will execute + """ + return self._actions + + def delete_actions(self): + """ + Removes all the action associated with this state. A cleared state will + not do anything when entered or exited. + """ + self._actions = {} + + # Transition manipulations + def add_transition(self, new_transition): + """ + Adds a transition from this state to another state. + + The same transition may not be added twice. + + @param transition The new transition. + @return A unique name for the transition + @raise TransitionAlreadyExists if an equivalent transition exists + """ + for transition in self._transitions.itervalues(): + if transition == new_transition: + raise TransitionAlreadyExists(str(transition)) + + transition_name = self._generate_unique_transition_name(new_transition) + self._transitions[transition_name] = new_transition + return transition_name + + def update_transition(self, transition_name, new_properties=None, new_state=None): + """ + Update the transition with transition_name with new properties and/or + a new state to transition to. A None value means that the corresponding + value won't be updated. If one property update is invalid, the old + values are restored and an exception is raised. + + @param transition_name The name of the transition to replace + @param new_properties The properties that will update the event on the transition + @param new_state The new state to transition to + @return a tuple (old_properties, old_state) with previous values + @raise LookupError if transition_name doesn't exist + @raise ConstraintException if a property constraint is violated + """ + if not self._transitions.has_key(transition_name): + raise LookupError("Tutorial.State: transition <" + transition_name + "> is not defined") + + transition = self._transitions[transition_name] + + tmp_event = transition[0] + tmp_state = transition[1] + + prop = new_properties or {} + + old_properties = transition[0].get_properties_dict_copy() + old_state = transition[1] + + try: + for property_name, property_value in prop.iteritems(): + tmp_event.__setattr__(property_name, property_value) + except ConstraintException, e: + tmp_event._props = old_properties + raise e + + if new_state: + tmp_state = new_state + + self._transitions[transition_name] = (tmp_event, tmp_state) + + return (old_properties, old_state) + + def delete_transition(self, transition_name): + """ + Delete the transition with the name transition_name + + @param transition_name The name of the transition to delete + @return The transition deleted + @raise LookupError if transition_name doesn't exist + """ + if self._transitions.has_key(transition_name): + return self._transitions.pop(transition_name) + else: + raise LookupError("Tutorial.State: transition <" + transition_name + "> is not defined") + + def get_transition_dict(self): + """ + Return the reference to the internal transition dictionary. + + @return The dictionary of transitions associated with this state. + """ + return self._transitions + + def delete_transitions(self): + """ + Delete all the transitions associated with this state. + """ + self._transitions = {} + + def _generate_unique_action_name(self, action): + """ + Returns a unique name for the action in this state, + the actual content of the name should not be relied upon + for correct behavior + + @param action The action to generate a name for + @return A name garanteed to be unique within this state + """ + #TODO use the action class name to generate a name + # to make it easier to debug and know what we are + # manipulating + name = self.name + _NAME_SEPARATOR + "action" + str(self.action_name_nb) + self.action_name_nb += 1 + return name + + def _generate_unique_transition_name(self, transition): + """ + Returns a unique name for the transition in this state, + the actual content of the name should not be relied upon + for correct behavior + + @param transition The transition to generate a name for + @return A name garanteed to be unique within this state + """ + #TODO use the event class name from the transition to + # generate a name to make it easier to debug and know + # what we are manipulating + name = self.name + _NAME_SEPARATOR + "transition" + str(self.transition_name_nb) + self.transition_name_nb += 1 + return name + + def __eq__(self, otherState): + """ + Compare current state to otherState. + + Two states are considered equal if and only if: + -every action in this state has a matching action in the + other state with the same properties and values + -every event filters in this state has a matching filter in the + other state having the same properties and values + -both states have the same name. + + + @param otherState The state that will be compared to this one + @return True if the states are the same, False otherwise +` """ + raise NotImplementedError + +#TODO: Define the automatic transition in the same way as +# other events +class AutomaticTransitionEvent(TPropContainer): + pass + + +################## Error Handling and Exceptions ############################## + +class TransitionAlreadyExists(Exception): + """ + Raised when a duplicate transition is added to a state + """ + pass + + +class InvalidStateDictionary(Exception): + """ + Raised when an initialization dictionary could not be used to initialize + a tutorial + """ + pass + +class StateDeletionError(Exception): + """ + Raised when trying to delete an INIT or an END state from a tutorial + """ + pass diff --git a/tutorius/vault.py b/tutorius/vault.py index 9215e8d..b455a52 100644 --- a/tutorius/vault.py +++ b/tutorius/vault.py @@ -28,11 +28,11 @@ import uuid import xml.dom.minidom from xml.dom import NotFoundErr import zipfile - -from sugar.tutorius import addon -from sugar.tutorius.core import Tutorial, State, FiniteStateMachine from ConfigParser import SafeConfigParser +from . import addon +from .core import Tutorial, State, FiniteStateMachine + logger = logging.getLogger("tutorius") # this is where user installed/generated tutorials will go @@ -73,7 +73,7 @@ class Vault(object): given activity. @param activity_name the name of the activity associated with this tutorial. None means ALL activities - @param activity_vers the version number of the activity to find tutorail for. 0 means find for ANY version. Ifactivity_ame is None, version number is not used + @param activity_vers the version number of the activity to find tutorail for. 0 means find for ANY version. If activity_name is None, version number is not used @returns a map of tutorial {names : GUID}. """ # check both under the activity data and user installed folders @@ -835,8 +835,7 @@ class TutorialBundler(object): raise IOError(2, "Neither the global nor the bundle directory contained the tutorial with GUID %s"%guid) - @staticmethod - def write_fsm(fsm): + def write_fsm(self, fsm): """ Save fsm to disk. If a GUID parameter is provided, the existing GUID is diff --git a/tutorius/viewer.py b/tutorius/viewer.py new file mode 100644 index 0000000..272558e --- /dev/null +++ b/tutorius/viewer.py @@ -0,0 +1,423 @@ +# Copyright (C) 2009, Tutorius.org +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +""" +This module renders a widget containing a graphical representation +of a tutorial and acts as a creator proxy as it has some editing +functionality. +""" +import sys + +import gtk, gtk.gdk +import cairo +from math import pi as PI +PI2 = PI/2 + +import rsvg + +from sugar.bundle import activitybundle +from sugar.tutorius import addon +from sugar.graphics import icon +from sugar.tutorius.filters import EventFilter +from sugar.tutorius.actions import Action +import os + +# FIXME ideally, apps scale correctly and we should use proportional positions +X_WIDTH = 800 +X_HEIGHT = 600 +ACTION_WIDTH = 100 +ACTION_HEIGHT = 70 + +# block look +BLOCK_PADDING = 5 +BLOCK_WIDTH = 100 +BLOCK_CORNERS = 10 +BLOCK_INNER_PAD = 10 + +SNAP_WIDTH = BLOCK_WIDTH - BLOCK_PADDING - BLOCK_INNER_PAD*2 +SNAP_HEIGHT = SNAP_WIDTH*X_HEIGHT/X_WIDTH +SNAP_SCALE = float(SNAP_WIDTH)/X_WIDTH + +class Viewer(object): + """ + Renders a tutorial as a sequence of blocks, each block representing either + an action or an event (transition). + + Current Viewer implementation lacks viewport management; + having many objects in a tutorial will not render properly. + """ + def __init__(self, tutorial, creator): + super(Viewer, self).__init__() + + self._tutorial = tutorial + self._creator = creator + self.alloc = None + self.click_pos = None + self.drag_pos = None + self.selection = [] + + self.win = gtk.Window(gtk.WINDOW_TOPLEVEL) + self.win.set_size_request(400, 200) + self.win.set_gravity(gtk.gdk.GRAVITY_SOUTH_WEST) + self.win.show() + self.win.set_deletable(False) + self.win.move(0, 0) + + vbox = gtk.ScrolledWindow() + self.win.add(vbox) + + canvas = gtk.DrawingArea() + vbox.add_with_viewport(canvas) + canvas.set_app_paintable(True) + canvas.connect_after("expose-event", self.on_viewer_expose, tutorial._states) + canvas.add_events(gtk.gdk.BUTTON_PRESS_MASK \ + |gtk.gdk.BUTTON_MOTION_MASK \ + |gtk.gdk.BUTTON_RELEASE_MASK \ + |gtk.gdk.KEY_PRESS_MASK) + canvas.connect('button-press-event', self._on_click) + # drag-select disabled, for now + #canvas.connect('motion-notify-event', self._on_drag) + canvas.connect('button-release-event', self._on_drag_end) + canvas.connect('key-press-event', self._on_key_press) + + canvas.set_flags(gtk.HAS_FOCUS|gtk.CAN_FOCUS) + canvas.grab_focus() + + self.win.show_all() + canvas.set_size_request(2048, 180) # FIXME + + def destroy(self): + self.win.destroy() + + + def _paint_state(self, ctx, states): + """ + Paints a tutorius fsm state in a cairo context. + Final context state will be shifted by the size of the graphics. + """ + block_width = BLOCK_WIDTH - BLOCK_PADDING + block_max_height = self.alloc.height + + new_insert_point = None + cur_state = 'INIT' + + # FIXME: get app when we have a model that supports it + cur_app = 'Calculate' + app_start = ctx.get_matrix() + try: + state = states[cur_state] + except KeyError: + state = None + + while state: + new_app = 'Calculate' + if new_app != cur_app: + ctx.save() + ctx.set_matrix(app_start) + self._render_app_hints(ctx, cur_app) + ctx.restore() + app_start = ctx.get_matrix() + ctx.translate(BLOCK_PADDING, 0) + cur_app = new_app + + action_list = state.get_action_list() + if action_list: + local_height = (block_max_height - BLOCK_PADDING)/len(action_list) - BLOCK_PADDING + ctx.save() + for action in action_list: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos and \ + self.click_pos[0]-BLOCK_WIDTHorigin[0]: + self.selection.append(action) + self.render_action(ctx, block_width, local_height, action) + ctx.translate(0, local_height+BLOCK_PADDING) + + ctx.restore() + ctx.translate(BLOCK_WIDTH, 0) + + # insertion cursor painting made from two opposed triangles + # joined by a line. + if state.name == self._creator.get_insertion_point(): + ctx.save() + bp2 = BLOCK_PADDING/2 + ctx.move_to(-bp2, 0) + ctx.line_to(-BLOCK_PADDING-bp2, -BLOCK_PADDING) + ctx.line_to(bp2, -BLOCK_PADDING) + ctx.line_to(-bp2, 0) + + ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING) + ctx.line_to(bp2, block_max_height-BLOCK_PADDING) + ctx.line_to(-BLOCK_PADDING-bp2, block_max_height-BLOCK_PADDING) + ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING) + + ctx.line_to(-bp2, BLOCK_PADDING) + ctx.set_source_rgb(1.0, 1.0, 0.0) + ctx.stroke_preserve() + ctx.fill() + ctx.restore() + + + event_list = state.get_event_filter_list() + if event_list: + local_height = (block_max_height - BLOCK_PADDING)/len(event_list) - BLOCK_PADDING + ctx.save() + for event, next_state in event_list: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos and \ + self.click_pos[0]-BLOCK_WIDTHorigin[0]: + self.selection.append(event) + self.render_event(ctx, block_width, local_height, event) + ctx.translate(0, local_height+BLOCK_PADDING) + + ctx.restore() + ctx.translate(BLOCK_WIDTH, 0) + + # FIXME point to next state in state, as it would highlight + # the "happy path". + cur_state = event_list[0][1] + + if (not new_insert_point) and self.click_pos: + origin = tuple(ctx.get_matrix())[-2:] + if self.click_pos[0]