Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--addons/bubblemessage.py8
-rw-r--r--addons/chainaction.py2
-rw-r--r--addons/clickaction.py4
-rw-r--r--addons/dialogmessage.py2
-rw-r--r--addons/disablewidget.py6
-rw-r--r--addons/gtkwidgeteventfilter.py10
-rw-r--r--addons/gtkwidgettypefilter.py8
-rw-r--r--addons/oncewrapper.py2
-rw-r--r--addons/readfile.py6
-rw-r--r--addons/timerevent.py4
-rw-r--r--addons/triggereventfilter.py10
-rw-r--r--addons/typetextaction.py4
-rw-r--r--addons/widgetidentifyaction.py4
-rw-r--r--data/icons/clock.svg4
-rw-r--r--data/ui/creator.glade209
-rwxr-xr-xsetup.py6
-rw-r--r--tests/coretests.py62
-rw-r--r--tests/filterstests.py20
-rw-r--r--tests/linear_creatortests.py8
-rw-r--r--tests/probetests.py483
-rw-r--r--tests/skip1
-rw-r--r--tests/storetests.py83
-rw-r--r--tests/tutorialtests.py416
-rw-r--r--tests/utils.py49
-rw-r--r--tutorius/TProbe.py32
-rw-r--r--tutorius/actions.py7
-rw-r--r--tutorius/addon.py14
-rw-r--r--tutorius/apilib/__init__.py0
-rw-r--r--tutorius/apilib/__init__.pycbin0 -> 105 bytes
-rw-r--r--tutorius/apilib/httplib2/.svn/all-wcprops29
-rw-r--r--tutorius/apilib/httplib2/.svn/entries66
-rw-r--r--tutorius/apilib/httplib2/.svn/format1
-rw-r--r--tutorius/apilib/httplib2/.svn/prop-base/__init__.pyc.svn-base5
-rw-r--r--tutorius/apilib/httplib2/.svn/prop-base/iri2uri.pyc.svn-base5
-rw-r--r--tutorius/apilib/httplib2/.svn/text-base/__init__.py.svn-base1123
-rw-r--r--tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-basebin0 -> 39171 bytes
-rw-r--r--tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base110
-rw-r--r--tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-basebin0 -> 3632 bytes
-rw-r--r--tutorius/apilib/httplib2/__init__.py1123
-rw-r--r--tutorius/apilib/httplib2/__init__.pycbin0 -> 39750 bytes
-rw-r--r--tutorius/apilib/httplib2/iri2uri.py110
-rw-r--r--tutorius/apilib/httplib2/iri2uri.pycbin0 -> 3674 bytes
-rw-r--r--tutorius/apilib/mimeTypes.py57
-rw-r--r--tutorius/apilib/mimeTypes.pycbin0 -> 2249 bytes
-rw-r--r--tutorius/apilib/restful_lib.py129
-rw-r--r--tutorius/apilib/restful_lib.pycbin0 -> 4972 bytes
-rw-r--r--tutorius/constraints.py22
-rw-r--r--tutorius/core.py33
-rw-r--r--tutorius/creator.py646
-rw-r--r--tutorius/dbustools.py1
-rw-r--r--tutorius/editor.py2
-rw-r--r--tutorius/engine.py8
-rw-r--r--tutorius/filters.py2
-rw-r--r--tutorius/linear_creator.py8
-rw-r--r--tutorius/overlayer.py4
-rw-r--r--tutorius/properties.py24
-rw-r--r--tutorius/service.py4
-rw-r--r--tutorius/store.py398
-rw-r--r--tutorius/tutorial.py806
-rw-r--r--tutorius/vault.py11
-rw-r--r--tutorius/viewer.py423
61 files changed, 6182 insertions, 432 deletions
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 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
-<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://web.resource.org/cc/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="231" height="231" id="svg2" sodipodi:version="0.32" inkscape:version="0.44+devel" sodipodi:docbase="C:\Documents and Settings\Molumen\Desktop" sodipodi:docname="clock_beige.svg" inkscape:output_extension="org.inkscape.output.svg.inkscape" sodipodi:modified="true" version="1.0">
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://web.resource.org/cc/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="51" height="51" id="svg2" sodipodi:version="0.32" inkscape:version="0.44+devel" sodipodi:docbase="C:\Documents and Settings\Molumen\Desktop" sodipodi:docname="clock_beige.svg" inkscape:output_extension="org.inkscape.output.svg.inkscape" sodipodi:modified="true" version="1.0">
<defs id="defs4">
<linearGradient y2="84.524567" x2="302" y1="365.95651" x1="302" gradientUnits="userSpaceOnUse" id="linearGradient20470" xlink:href="#linearGradient13034" inkscape:collect="always"/>
<radialGradient r="90.78125" fy="691.20294" fx="527" cy="691.20294" cx="527" gradientTransform="matrix(1, 0, 0, 0.231842, -340, 200.219)" gradientUnits="userSpaceOnUse" id="radialGradient20468" xlink:href="#linearGradient12977" inkscape:collect="always"/>
@@ -266,4 +266,4 @@
<path sodipodi:ry="138" sodipodi:rx="138" transform="matrix(0.728261, 0, 0, 0.601449, 306.065, 286.927)" d="M 440,288.36218 A 138,138 0 1 1 164,288.36218 A 138,138 0 1 1 440,288.36218 z" sodipodi:type="arc" sodipodi:cy="288.36218" sodipodi:cx="302" id="path19429" style="fill: url(#linearGradient19441) rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; stroke: none; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0pt; stroke-opacity: 1;"/>
</g>
</g>
-</svg> \ No newline at end of file
+</svg>
diff --git a/data/ui/creator.glade b/data/ui/creator.glade
new file mode 100644
index 0000000..1c9669d
--- /dev/null
+++ b/data/ui/creator.glade
@@ -0,0 +1,209 @@
+<?xml version="1.0"?>
+<glade-interface>
+ <!-- interface-requires gtk+ 2.16 -->
+ <!-- interface-naming-policy project-wide -->
+ <widget class="GtkWindow" id="mainwindow">
+ <property name="width_request">300</property>
+ <property name="height_request">500</property>
+ <property name="title" translatable="yes">Toolbox</property>
+ <property name="resizable">False</property>
+ <property name="window_position">center-on-parent</property>
+ <property name="default_width">200</property>
+ <property name="default_height">500</property>
+ <property name="destroy_with_parent">True</property>
+ <property name="skip_taskbar_hint">True</property>
+ <property name="skip_pager_hint">True</property>
+ <property name="focus_on_map">False</property>
+ <property name="deletable">False</property>
+ <signal name="destroy" handler="on_mainwindow_destroy"/>
+ <child>
+ <widget class="GtkVBox" id="vbox1">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">5</property>
+ <child>
+ <widget class="GtkHButtonBox" id="hbuttonbox1">
+ <property name="visible">True</property>
+ <property name="spacing">5</property>
+ <property name="layout_style">start</property>
+ <child>
+ <widget class="GtkButton" id="button2">
+ <property name="label">gtk-save</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="on_save_clicked"/>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkButton" id="button4">
+ <property name="label">gtk-quit</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <signal name="clicked" handler="on_quit_clicked"/>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkScrolledWindow" id="scrolledwindow1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">never</property>
+ <property name="vscrollbar_policy">automatic</property>
+ <property name="shadow_type">in</property>
+ <child>
+ <widget class="GtkViewport" id="viewport1">
+ <property name="visible">True</property>
+ <property name="resize_mode">queue</property>
+ <child>
+ <widget class="GtkVBox" id="vbox2">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <widget class="GtkExpander" id="expander1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <widget class="GtkIconView" id="iconview1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="columns">2</property>
+ <property name="row_spacing">0</property>
+ <property name="column_spacing">0</property>
+ <property name="item_padding">0</property>
+ <signal name="item_activated" handler="on_action_activate"/>
+ </widget>
+ </child>
+ <child>
+ <widget class="GtkLabel" id="label3">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">actions</property>
+ </widget>
+ <packing>
+ <property name="type">label_item</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkExpander" id="expander2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <widget class="GtkIconView" id="iconview2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="columns">2</property>
+ <property name="row_spacing">0</property>
+ <property name="column_spacing">0</property>
+ <property name="item_padding">0</property>
+ <signal name="item_activated" handler="on_event_activate"/>
+ </widget>
+ </child>
+ <child>
+ <widget class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">events</property>
+ </widget>
+ <packing>
+ <property name="type">label_item</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ </child>
+ </widget>
+ </child>
+ </widget>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkVBox" id="propbox">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">10</property>
+ <child>
+ <placeholder/>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="padding">5</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkHButtonBox" id="hbuttonbox2">
+ <property name="visible">True</property>
+ <property name="spacing">5</property>
+ <property name="layout_style">start</property>
+ <child>
+ <widget class="GtkButton" id="button1">
+ <property name="label">gtk-media-record</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <widget class="GtkButton" id="button3">
+ <property name="label">gtk-media-stop</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </widget>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ </widget>
+ </child>
+ </widget>
+</glade-interface>
diff --git a/setup.py b/setup.py
index 9362dc7..e33873c 100755
--- a/setup.py
+++ b/setup.py
@@ -93,13 +93,17 @@ setup(name='Tutorius',
'sugar.tutorius',
'sugar.tutorius.uam',
'sugar.tutorius.addons',
+ 'sugar.tutorius.apilib',
+ 'sugar.tutorius.apilib.httplib2',
],
package_dir={
'sugar.tutorius': 'tutorius',
'sugar.tutorius.addons': 'addons',
},
cmdclass = {'test': TestCommand},
- data_files=[('share/icons/sugar/scalable/actions', glob.glob('data/icons/*.svg')),]
+ data_files=[('share/icons/sugar/scalable/actions', glob.glob('data/icons/*.svg')),
+ ('share/tutorius/ui', glob.glob('data/ui/*.glade')),
+ ]
)
# vim: set et sw=4 sts=4 ts=4:
diff --git a/tests/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 <erick.lavoie@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+Tutorial Tests
+"""
+
+# TODO: Add tests for 47, 52, 55, 109, 132, 175, 209, 229, 233, 271, 274, 292, 295, 318, 337, 375, 394, 411, 428, 446, 480, 491, 624, 637, 698
+import unittest
+
+from sugar.tutorius.tutorial import *
+
+# The following tests are organized around 4 classes:
+#
+# Black box tests:
+# Those tests should limit themselves to exercise the
+# interface of the object so everything should be tested
+# only through the interface the object offers. This will
+# ease test maintenance since we anticipate most changes
+# will be about the implementation of an object and not
+# its interface.
+#
+# Tests definitions are written assuming the previous tests
+# did complete correctly so the number of things to assert
+# is minimal.
+#
+# Basic interface cases:
+# Test the interface of the object for trivial cases
+# just to assert that the functionality this object
+# offers really works
+#
+# Limit cases:
+# Test edge cases that cover more obscure usage
+# scenarios but that should be valid nonetheless
+#
+# Error cases:
+# Test wrong inputs to make sure that the object is hard
+# to misuse and do generate proper errors
+#
+# White box tests:
+# Those should be used only for really important algorithms
+# to make sure they behave correctly in every cases, otherwise
+# the tests will break each time we change something in the
+# implementation
+
+from sugar.tutorius.properties import *
+
+class MockAction(TPropContainer):
+ i = TIntProperty(0, 0, 9)
+
+class MockEvent(TPropContainer):
+ i = TIntProperty(0, 0, 9)
+
+class StateTest(unittest.TestCase):
+ """Test basic functionalities of states used by tutorials"""
+
+ def setUp(self):
+ self.state = State("State1")
+ self.action = MockAction()
+ self.event = MockEvent()
+
+ def tearDown(self):
+ pass
+
+ ######################### Basic interface cases #########################
+
+ #### Action
+ def test_add_dummy_action(self):
+ action_name = self.state.add_action("action1")
+ assert action_name
+ assert len(self.state.get_action_dict()) == 1
+ assert self.state.get_action_dict().has_key(action_name)
+ assert self.state.get_action_dict()[action_name] == "action1"
+
+ def test_add_generate_unique_action_names(self):
+ action_name1 = self.state.add_action("action1")
+ action_name2 = self.state.add_action("action2")
+ assert action_name1 and action_name2
+ assert action_name1 != action_name2
+
+ def test_update_dummy_action(self):
+ action_name = self.state.add_action(self.action)
+ assert self.action.i == 0
+
+ prop = self.action.get_properties_dict_copy()
+ prop["i"] = 2
+ self.state.update_action(action_name, prop)
+
+ assert len(self.state.get_action_dict()) == 1
+ assert self.state.get_action_dict().has_key(action_name)
+ assert self.state.get_action_dict()[action_name].get_properties_dict_copy() == prop
+
+ def test_delete_dummy_action(self):
+ action_name = self.state.add_action("action1")
+ assert len(self.state.get_action_dict()) == 1
+ assert self.state.get_action_dict().has_key(action_name)
+ assert self.state.get_action_dict()[action_name] == "action1"
+
+ self.state.delete_action(action_name)
+ assert len(self.state.get_action_dict()) == 0
+
+ def test_delete_all_dummy_actions(self):
+ action_name = self.state.add_action("action1")
+ assert len(self.state.get_action_dict()) == 1
+ assert self.state.get_action_dict().has_key(action_name)
+ assert self.state.get_action_dict()[action_name] == "action1"
+
+ self.state.delete_actions()
+ assert len(self.state.get_action_dict()) == 0
+
+ #### Transition
+ def test_add_dummy_transition(self):
+ transition_name = self.state.add_transition("transition1")
+ assert len(self.state.get_transition_dict()) == 1
+ assert self.state.get_transition_dict().has_key(transition_name)
+ assert self.state.get_transition_dict()[transition_name] == "transition1"
+
+ def test_add_generate_unique_transition_names(self):
+ transition_name1 = self.state.add_transition("transition1")
+ transition_name2 = self.state.add_transition("transition2")
+ assert transition_name1 != transition_name2
+
+ def test_update_dummy_transition(self):
+ transition_name = self.state.add_transition((self.event, Tutorial.END))
+ assert self.event.i == 0
+
+ prop = self.event.get_properties_dict_copy()
+ prop["i"] = 2
+ self.state.update_transition(transition_name, prop)
+
+ assert len(self.state.get_transition_dict()) == 1
+ assert self.state.get_transition_dict().has_key(transition_name)
+ assert self.state.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop
+ assert self.state.get_transition_dict()[transition_name][1] == Tutorial.END
+
+ # Now update only the transition
+ self.state.update_transition(transition_name, new_state=Tutorial.INIT)
+ assert self.state.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop
+ assert self.state.get_transition_dict()[transition_name][1] == Tutorial.INIT
+
+ def test_delete_dummy_transition(self):
+ transition_name = self.state.add_transition("transition1")
+ assert len(self.state.get_transition_dict()) == 1
+ assert self.state.get_transition_dict().has_key(transition_name)
+ assert self.state.get_transition_dict()[transition_name] == "transition1"
+
+ self.state.delete_transition(transition_name)
+ assert len(self.state.get_transition_dict()) == 0
+
+ def test_delete_all_dummy_transitions(self):
+ transition_name = self.state.add_transition("transition1")
+ assert len(self.state.get_transition_dict()) == 1
+ assert self.state.get_transition_dict().has_key(transition_name)
+ assert self.state.get_transition_dict()[transition_name] == "transition1"
+
+ self.state.delete_transitions()
+ assert len(self.state.get_transition_dict()) == 0
+
+
+
+ ######################### Limit cases ###################################
+ #### Action
+
+ #### Transition
+
+ ######################### Error cases ###################################
+ #### Action
+ def test_update_unknown_action(self):
+ try:
+ self.state.update_action("unknown_name", "action")
+ assert False
+ except LookupError:
+ pass
+
+ def test_delete_unknown_action(self):
+ try:
+ self.state.delete_action("unknown_name")
+ assert False
+ except LookupError:
+ pass
+
+ #### Transition
+ def test_add_existing_transition(self):
+ self.state.add_transition("transition")
+ try:
+ self.state.add_transition("transition")
+ assert False
+ except TransitionAlreadyExists:
+ pass
+
+class TutorialTest(unittest.TestCase):
+ """Test tutorial functionality"""
+
+ def setUp(self):
+ self.tutorial = Tutorial("Tutorial Test")
+ self.action = MockAction()
+ self.event = MockEvent()
+
+ def tearDown(self):
+ pass
+
+ ######################### Basic interface cases #########################
+
+ #### Tutorial
+ def test_default_initial_value_in_tutorial(self):
+ assert len(self.tutorial.get_state_dict()) == 2
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 1
+ assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {}
+ assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END]
+ assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT]
+ assert self.tutorial.get_following_states_dict(Tutorial.END) == {}
+
+ #### State
+ def test_add_default_state(self):
+ state_name = self.tutorial.add_state()
+ assert state_name
+ assert len(self.tutorial.get_state_dict()) == 3
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 1
+
+ def test_add_state_with_action(self):
+ state_name = self.tutorial.add_state(action_list=["action1"])
+ assert state_name
+ assert len(self.tutorial.get_state_dict()) == 3
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name])
+ assert len(self.tutorial.get_action_dict()) == 1
+ assert len(self.tutorial.get_transition_dict()) == 1
+
+ def test_add_state_with_transition(self):
+ state_name = self.tutorial.add_state(transition_list=[("event1",Tutorial.END)])
+ assert state_name
+ assert len(self.tutorial.get_state_dict()) == 3
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END, state_name])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 2
+
+ def test_add_generate_unique_state_names(self):
+ state_name1 = self.tutorial.add_state()
+ state_name2 = self.tutorial.add_state()
+ assert state_name1 and state_name2
+ assert state_name1 != state_name2
+
+ def test_delete_lone_state(self):
+ state_name1 = self.tutorial.add_state()
+ self.tutorial.delete_state(state_name1)
+ assert len(self.tutorial.get_state_dict()) == 2
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 1
+ assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {}
+ assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END]
+ assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT]
+ assert self.tutorial.get_following_states_dict(Tutorial.END) == {}
+
+ def test_delete_linked_state(self):
+ state_name1 = self.tutorial.add_state()
+ self.tutorial.update_transition(Tutorial.INITIAL_TRANSITION_NAME, \
+ None, state_name1)
+ transition_name1 = self.tutorial.add_transition(state_name1,("event1", Tutorial.END))
+ self.tutorial.delete_state(state_name1)
+ assert len(self.tutorial.get_state_dict()) == 2
+ assert set(self.tutorial.get_state_dict().keys()) == set([Tutorial.INIT,Tutorial.END])
+ assert len(self.tutorial.get_action_dict()) == 0
+ assert len(self.tutorial.get_transition_dict()) == 1
+ assert self.tutorial.get_previous_states_dict(Tutorial.INIT) == {}
+ assert self.tutorial.get_following_states_dict(Tutorial.INIT).keys() == [Tutorial.END]
+ assert self.tutorial.get_previous_states_dict(Tutorial.END).keys() == [Tutorial.INIT]
+ assert self.tutorial.get_following_states_dict(Tutorial.END) == {}
+
+ #### Action
+ def test_add_dummy_action(self):
+ state_name = self.tutorial.add_state()
+ action_name = self.tutorial.add_action(state_name,"action1")
+ assert action_name
+ assert len(self.tutorial.get_action_dict()) == 1
+ assert self.tutorial.get_action_dict().has_key(action_name)
+ assert self.tutorial.get_action_dict()[action_name] == "action1"
+
+ def test_add_generate_unique_action_names(self):
+ state_name = self.tutorial.add_state()
+ action_name1 = self.tutorial.add_action(state_name,"action1")
+ action_name2 = self.tutorial.add_action(state_name,"action2")
+ assert action_name1 and action_name2
+ assert action_name1 != action_name2
+
+ def test_update_dummy_action(self):
+ state_name = self.tutorial.add_state()
+ action_name = self.tutorial.add_action(state_name,self.action)
+
+ prop = self.action.get_properties_dict_copy()
+ prop["i"] = 2
+ self.tutorial.update_action(action_name, prop)
+
+ assert len(self.tutorial.get_action_dict()) == 1
+ assert self.tutorial.get_action_dict().has_key(action_name)
+ assert self.tutorial.get_action_dict()[action_name].get_properties_dict_copy() == prop
+
+ def test_delete_dummy_action(self):
+ state_name = self.tutorial.add_state()
+ action_name = self.tutorial.add_action(state_name,"action1")
+ assert len(self.tutorial.get_action_dict()) == 1
+ assert self.tutorial.get_action_dict().has_key(action_name)
+ assert self.tutorial.get_action_dict()[action_name] == "action1"
+
+ self.tutorial.delete_action(action_name)
+ assert len(self.tutorial.get_action_dict()) == 0
+
+ #### Transition
+ def test_add_dummy_transition(self):
+ state_name = self.tutorial.add_state()
+ transition_name = self.tutorial.add_transition(state_name,"transition1")
+ assert len(self.tutorial.get_transition_dict()) == 2
+ assert self.tutorial.get_transition_dict().has_key(transition_name)
+ assert self.tutorial.get_transition_dict()[transition_name] == "transition1"
+
+ def test_add_generate_unique_transition_names(self):
+ state_name = self.tutorial.add_state()
+ transition_name1 = self.tutorial.add_transition(state_name,"transition1")
+ transition_name2 = self.tutorial.add_transition(state_name,"transition2")
+ assert transition_name1 and transition_name2
+ assert transition_name1 != transition_name2
+
+ def test_update_dummy_transition(self):
+ state_name = self.tutorial.add_state()
+ transition_name = self.tutorial.add_transition(state_name,(self.event, Tutorial.END))
+
+ prop = self.event.get_properties_dict_copy()
+ prop["i"] = 2
+ self.tutorial.update_transition(transition_name, prop)
+
+ assert len(self.tutorial.get_transition_dict()) == 2
+ assert self.tutorial.get_transition_dict().has_key(transition_name)
+ assert self.tutorial.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop
+ assert self.tutorial.get_transition_dict()[transition_name][1] == Tutorial.END
+
+ # Now update only the transition
+ self.tutorial.update_transition(transition_name, new_state=Tutorial.INIT)
+ assert self.tutorial.get_transition_dict()[transition_name][0].get_properties_dict_copy() == prop
+ assert self.tutorial.get_transition_dict()[transition_name][1] == Tutorial.INIT
+
+ def test_delete_dummy_transition(self):
+ state_name = self.tutorial.add_state()
+ transition_name = self.tutorial.add_transition(state_name,"transition1")
+ assert len(self.tutorial.get_transition_dict()) == 2
+ assert self.tutorial.get_transition_dict().has_key(transition_name)
+ assert self.tutorial.get_transition_dict()[transition_name] == "transition1"
+
+ self.tutorial.delete_transition(transition_name)
+ assert len(self.tutorial.get_transition_dict()) == 1
+
+
+ ######################### Limit cases ###################################
+ #### Tutorial
+
+ #### State
+
+ #### Action
+
+ #### Transition
+
+ ######################### Error cases ###################################
+ #### Tutorial
+
+ #### State
+
+ #### Action
+ def test_update_unknown_action(self):
+ lookup_error = None
+ try:
+ self.tutorial.update_action("unknown_name", "action")
+ except LookupError, e:
+ lookup_error = e
+
+ assert lookup_error
+
+
+ def test_delete_unknown_action(self):
+ lookup_error = None
+ try:
+ self.tutorial.delete_action("unknown_name")
+ except LookupError, e:
+ lookup_error = e
+
+ assert lookup_error
+
+ #### Transition
+ def test_add_existing_transition(self):
+ self.tutorial.add_transition(Tutorial.INIT,("event","transition"))
+ transition_exists_error = None
+ try:
+ self.tutorial.add_transition(Tutorial.INIT,("event","transition"))
+ except TransitionAlreadyExists, e:
+ transition_exists_error = e
+
+ assert transition_exists_error
+
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..98738b8
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,49 @@
+# Copyright (C) 2009, Tutorius.org
+# Copyright (C) 2009, Simon Poirier <simpoir@gmail.com>
+#
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+"""
+Here are some utility functions for easy tests maintenance
+"""
+
+def catch_unimplemented(fnct):
+ """
+ Decorator for globbing not implemented errors.
+ """
+ def ret(self, *args, **kwargs):
+ try:
+ fnct(self, *args, **kwargs)
+ print "PREVIOUSLY UNIMPLEMENTED TEST PASSES. REMOVE THIS DECORATOR: %s (%s.%s)"%\
+ (fnct.__name__, type(self).__module__, type(self).__name__)
+ except NotImplementedError:
+ pass
+ return ret
+
+
+def skip(msg):
+ """
+ Decorator for skipping pyunit tests.
+
+ @type msg: str
+ @param msg: reason for skipping the test
+ """
+ def ret(fnct):
+ print "SKIPPED TEST: %s (%s): %s"%(fnct.__name__, fnct.__module__, msg)
+ return ret
+
+
+# vim:set ts=4 sts=4 sw=4 et:
+
diff --git a/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])*?(?=\")|(?<!\")[^\0-\x08\x0A-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?
+WWW_AUTH_STRICT = re.compile(r"^(?:\s*(?:,\s*)?([^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+)\s*=\s*\"?((?<=\")(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?)(.*)$")
+WWW_AUTH_RELAXED = re.compile(r"^(?:\s*(?:,\s*)?([^ \t\r\n=]+)\s*=\s*\"?((?<=\")(?:[^\\\"]|\\.)*?(?=\")|(?<!\")[^ \t\r\n,]+(?!\"))\"?)(.*)$")
+UNQUOTE_PAIRS = re.compile(r'\\(.)')
+def _parse_www_authenticate(headers, headername='www-authenticate'):
+ """Returns a dictionary of dictionaries, one dict
+ per auth_scheme."""
+ retval = {}
+ if headers.has_key(headername):
+ authenticate = headers[headername].strip()
+ www_auth = USE_WWW_AUTH_STRICT_PARSING and WWW_AUTH_STRICT or WWW_AUTH_RELAXED
+ while authenticate:
+ # Break off the scheme at the beginning of the line
+ if headername == 'authentication-info':
+ (auth_scheme, the_rest) = ('digest', authenticate)
+ else:
+ (auth_scheme, the_rest) = authenticate.split(" ", 1)
+ # Now loop over all the key value pairs that come after the scheme,
+ # being careful not to roll into the next scheme
+ match = www_auth.search(the_rest)
+ auth_params = {}
+ while match:
+ if match and len(match.groups()) == 3:
+ (key, value, the_rest) = match.groups()
+ auth_params[key.lower()] = UNQUOTE_PAIRS.sub(r'\1', value) # '\\'.join([x.replace('\\', '') for x in value.split('\\\\')])
+ match = www_auth.search(the_rest)
+ retval[auth_scheme.lower()] = auth_params
+ authenticate = the_rest.strip()
+ return retval
+
+
+def _entry_disposition(response_headers, request_headers):
+ """Determine freshness from the Date, Expires and Cache-Control headers.
+
+ We don't handle the following:
+
+ 1. Cache-Control: max-stale
+ 2. Age: headers are not used in the calculations.
+
+ Not that this algorithm is simpler than you might think
+ because we are operating as a private (non-shared) cache.
+ This lets us ignore 's-maxage'. We can also ignore
+ 'proxy-invalidate' since we aren't a proxy.
+ We will never return a stale document as
+ fresh as a design decision, and thus the non-implementation
+ of 'max-stale'. This also lets us safely ignore 'must-revalidate'
+ since we operate as if every server has sent 'must-revalidate'.
+ Since we are private we get to ignore both 'public' and
+ 'private' parameters. We also ignore 'no-transform' since
+ we don't do any transformations.
+ The 'no-store' parameter is handled at a higher level.
+ So the only Cache-Control parameters we look at are:
+
+ no-cache
+ only-if-cached
+ max-age
+ min-fresh
+ """
+
+ retval = "STALE"
+ cc = _parse_cache_control(request_headers)
+ cc_response = _parse_cache_control(response_headers)
+
+ if request_headers.has_key('pragma') and request_headers['pragma'].lower().find('no-cache') != -1:
+ retval = "TRANSPARENT"
+ if 'cache-control' not in request_headers:
+ request_headers['cache-control'] = 'no-cache'
+ elif cc.has_key('no-cache'):
+ retval = "TRANSPARENT"
+ elif cc_response.has_key('no-cache'):
+ retval = "STALE"
+ elif cc.has_key('only-if-cached'):
+ retval = "FRESH"
+ elif response_headers.has_key('date'):
+ date = calendar.timegm(email.Utils.parsedate_tz(response_headers['date']))
+ now = time.time()
+ current_age = max(0, now - date)
+ if cc_response.has_key('max-age'):
+ try:
+ freshness_lifetime = int(cc_response['max-age'])
+ except ValueError:
+ freshness_lifetime = 0
+ elif response_headers.has_key('expires'):
+ expires = email.Utils.parsedate_tz(response_headers['expires'])
+ if None == expires:
+ freshness_lifetime = 0
+ else:
+ freshness_lifetime = max(0, calendar.timegm(expires) - date)
+ else:
+ freshness_lifetime = 0
+ if cc.has_key('max-age'):
+ try:
+ freshness_lifetime = int(cc['max-age'])
+ except ValueError:
+ freshness_lifetime = 0
+ if cc.has_key('min-fresh'):
+ try:
+ min_fresh = int(cc['min-fresh'])
+ except ValueError:
+ min_fresh = 0
+ current_age += min_fresh
+ if freshness_lifetime > current_age:
+ retval = "FRESH"
+ return retval
+
+def _decompressContent(response, new_content):
+ content = new_content
+ try:
+ encoding = response.get('content-encoding', None)
+ if encoding in ['gzip', 'deflate']:
+ if encoding == 'gzip':
+ content = gzip.GzipFile(fileobj=StringIO.StringIO(new_content)).read()
+ if encoding == 'deflate':
+ content = zlib.decompress(content)
+ response['content-length'] = str(len(content))
+ del response['content-encoding']
+ except IOError:
+ content = ""
+ raise FailedToDecompressContent(_("Content purported to be compressed with %s but failed to decompress.") % response.get('content-encoding'), response, content)
+ return content
+
+def _updateCache(request_headers, response_headers, content, cache, cachekey):
+ if cachekey:
+ cc = _parse_cache_control(request_headers)
+ cc_response = _parse_cache_control(response_headers)
+ if cc.has_key('no-store') or cc_response.has_key('no-store'):
+ cache.delete(cachekey)
+ else:
+ info = email.Message.Message()
+ for key, value in response_headers.iteritems():
+ if key not in ['status','content-encoding','transfer-encoding']:
+ info[key] = value
+
+ status = response_headers.status
+ if status == 304:
+ status = 200
+
+ status_header = 'status: %d\r\n' % response_headers.status
+
+ header_str = info.as_string()
+
+ header_str = re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", header_str)
+ text = "".join([status_header, header_str, content])
+
+ cache.set(cachekey, text)
+
+def _cnonce():
+ dig = md5.new("%s:%s" % (time.ctime(), ["0123456789"[random.randrange(0, 9)] for i in range(20)])).hexdigest()
+ return dig[:16]
+
+def _wsse_username_token(cnonce, iso_now, password):
+ return base64.encodestring(sha.new("%s%s%s" % (cnonce, iso_now, password)).digest()).strip()
+
+
+# For credentials we need two things, first
+# a pool of credential to try (not necesarily tied to BAsic, Digest, etc.)
+# Then we also need a list of URIs that have already demanded authentication
+# That list is tricky since sub-URIs can take the same auth, or the
+# auth scheme may change as you descend the tree.
+# So we also need each Auth instance to be able to tell us
+# how close to the 'top' it is.
+
+class Authentication(object):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ self.path = path
+ self.host = host
+ self.credentials = credentials
+ self.http = http
+
+ def depth(self, request_uri):
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ return request_uri[len(self.path):].count("/")
+
+ def inscope(self, host, request_uri):
+ # XXX Should we normalize the request_uri?
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ return (host == self.host) and path.startswith(self.path)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header. Over-rise this in sub-classes."""
+ pass
+
+ def response(self, response, content):
+ """Gives us a chance to update with new nonces
+ or such returned from the last authorized response.
+ Over-rise this in sub-classes if necessary.
+
+ Return TRUE is the request is to be retried, for
+ example Digest may return stale=true.
+ """
+ return False
+
+
+
+class BasicAuthentication(Authentication):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['authorization'] = 'Basic ' + base64.encodestring("%s:%s" % self.credentials).strip()
+
+
+class DigestAuthentication(Authentication):
+ """Only do qop='auth' and MD5, since that
+ is all Apache currently implements"""
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ self.challenge = challenge['digest']
+ qop = self.challenge.get('qop')
+ self.challenge['qop'] = ('auth' in [x.strip() for x in qop.split()]) and 'auth' or None
+ if self.challenge['qop'] is None:
+ raise UnimplementedDigestAuthOptionError( _("Unsupported value for qop: %s." % qop))
+ self.challenge['algorithm'] = self.challenge.get('algorithm', 'MD5')
+ if self.challenge['algorithm'] != 'MD5':
+ raise UnimplementedDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm']))
+ self.A1 = "".join([self.credentials[0], ":", self.challenge['realm'], ":", self.credentials[1]])
+ self.challenge['nc'] = 1
+
+ def request(self, method, request_uri, headers, content, cnonce = None):
+ """Modify the request headers"""
+ H = lambda x: md5.new(x).hexdigest()
+ KD = lambda s, d: H("%s:%s" % (s, d))
+ A2 = "".join([method, ":", request_uri])
+ self.challenge['cnonce'] = cnonce or _cnonce()
+ request_digest = '"%s"' % KD(H(self.A1), "%s:%s:%s:%s:%s" % (self.challenge['nonce'],
+ '%08x' % self.challenge['nc'],
+ self.challenge['cnonce'],
+ self.challenge['qop'], H(A2)
+ ))
+ headers['Authorization'] = 'Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm=%s, response=%s, qop=%s, nc=%08x, cnonce="%s"' % (
+ self.credentials[0],
+ self.challenge['realm'],
+ self.challenge['nonce'],
+ request_uri,
+ self.challenge['algorithm'],
+ request_digest,
+ self.challenge['qop'],
+ self.challenge['nc'],
+ self.challenge['cnonce'],
+ )
+ self.challenge['nc'] += 1
+
+ def response(self, response, content):
+ if not response.has_key('authentication-info'):
+ challenge = _parse_www_authenticate(response, 'www-authenticate').get('digest', {})
+ if 'true' == challenge.get('stale'):
+ self.challenge['nonce'] = challenge['nonce']
+ self.challenge['nc'] = 1
+ return True
+ else:
+ updated_challenge = _parse_www_authenticate(response, 'authentication-info').get('digest', {})
+
+ if updated_challenge.has_key('nextnonce'):
+ self.challenge['nonce'] = updated_challenge['nextnonce']
+ self.challenge['nc'] = 1
+ return False
+
+
+class HmacDigestAuthentication(Authentication):
+ """Adapted from Robert Sayre's code and DigestAuthentication above."""
+ __author__ = "Thomas Broyer (t.broyer@ltgt.net)"
+
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ self.challenge = challenge['hmacdigest']
+ # TODO: self.challenge['domain']
+ self.challenge['reason'] = self.challenge.get('reason', 'unauthorized')
+ if self.challenge['reason'] not in ['unauthorized', 'integrity']:
+ self.challenge['reason'] = 'unauthorized'
+ self.challenge['salt'] = self.challenge.get('salt', '')
+ if not self.challenge.get('snonce'):
+ raise UnimplementedHmacDigestAuthOptionError( _("The challenge doesn't contain a server nonce, or this one is empty."))
+ self.challenge['algorithm'] = self.challenge.get('algorithm', 'HMAC-SHA-1')
+ if self.challenge['algorithm'] not in ['HMAC-SHA-1', 'HMAC-MD5']:
+ raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm']))
+ self.challenge['pw-algorithm'] = self.challenge.get('pw-algorithm', 'SHA-1')
+ if self.challenge['pw-algorithm'] not in ['SHA-1', 'MD5']:
+ raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for pw-algorithm: %s." % self.challenge['pw-algorithm']))
+ if self.challenge['algorithm'] == 'HMAC-MD5':
+ self.hashmod = md5
+ else:
+ self.hashmod = sha
+ if self.challenge['pw-algorithm'] == 'MD5':
+ self.pwhashmod = md5
+ else:
+ self.pwhashmod = sha
+ self.key = "".join([self.credentials[0], ":",
+ self.pwhashmod.new("".join([self.credentials[1], self.challenge['salt']])).hexdigest().lower(),
+ ":", self.challenge['realm']
+ ])
+ self.key = self.pwhashmod.new(self.key).hexdigest().lower()
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers"""
+ keys = _get_end2end_headers(headers)
+ keylist = "".join(["%s " % k for k in keys])
+ headers_val = "".join([headers[k] for k in keys])
+ created = time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
+ cnonce = _cnonce()
+ request_digest = "%s:%s:%s:%s:%s" % (method, request_uri, cnonce, self.challenge['snonce'], headers_val)
+ request_digest = hmac.new(self.key, request_digest, self.hashmod).hexdigest().lower()
+ headers['Authorization'] = 'HMACDigest username="%s", realm="%s", snonce="%s", cnonce="%s", uri="%s", created="%s", response="%s", headers="%s"' % (
+ self.credentials[0],
+ self.challenge['realm'],
+ self.challenge['snonce'],
+ cnonce,
+ request_uri,
+ created,
+ request_digest,
+ keylist,
+ )
+
+ def response(self, response, content):
+ challenge = _parse_www_authenticate(response, 'www-authenticate').get('hmacdigest', {})
+ if challenge.get('reason') in ['integrity', 'stale']:
+ return True
+ return False
+
+
+class WsseAuthentication(Authentication):
+ """This is thinly tested and should not be relied upon.
+ At this time there isn't any third party server to test against.
+ Blogger and TypePad implemented this algorithm at one point
+ but Blogger has since switched to Basic over HTTPS and
+ TypePad has implemented it wrong, by never issuing a 401
+ challenge but instead requiring your client to telepathically know that
+ their endpoint is expecting WSSE profile="UsernameToken"."""
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['Authorization'] = 'WSSE profile="UsernameToken"'
+ iso_now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ cnonce = _cnonce()
+ password_digest = _wsse_username_token(cnonce, iso_now, self.credentials[1])
+ headers['X-WSSE'] = 'UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s"' % (
+ self.credentials[0],
+ password_digest,
+ cnonce,
+ iso_now)
+
+class GoogleLoginAuthentication(Authentication):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ from urllib import urlencode
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ service = challenge['googlelogin'].get('service', 'xapi')
+ # Bloggger actually returns the service in the challenge
+ # For the rest we guess based on the URI
+ if service == 'xapi' and request_uri.find("calendar") > 0:
+ service = "cl"
+ # No point in guessing Base or Spreadsheet
+ #elif request_uri.find("spreadsheets") > 0:
+ # service = "wise"
+
+ auth = dict(Email=credentials[0], Passwd=credentials[1], service=service, source=headers['user-agent'])
+ resp, content = self.http.request("https://www.google.com/accounts/ClientLogin", method="POST", body=urlencode(auth), headers={'Content-Type': 'application/x-www-form-urlencoded'})
+ lines = content.split('\n')
+ d = dict([tuple(line.split("=", 1)) for line in lines if line])
+ if resp.status == 403:
+ self.Auth = ""
+ else:
+ self.Auth = d['Auth']
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['authorization'] = 'GoogleLogin Auth=' + self.Auth
+
+
+AUTH_SCHEME_CLASSES = {
+ "basic": BasicAuthentication,
+ "wsse": WsseAuthentication,
+ "digest": DigestAuthentication,
+ "hmacdigest": HmacDigestAuthentication,
+ "googlelogin": GoogleLoginAuthentication
+}
+
+AUTH_SCHEME_ORDER = ["hmacdigest", "googlelogin", "digest", "wsse", "basic"]
+
+def _md5(s):
+ return
+
+class FileCache(object):
+ """Uses a local directory as a store for cached files.
+ Not really safe to use if multiple threads or processes are going to
+ be running on the same cache.
+ """
+ def __init__(self, cache, safe=safename): # use safe=lambda x: md5.new(x).hexdigest() for the old behavior
+ self.cache = cache
+ self.safe = safe
+ if not os.path.exists(cache):
+ os.makedirs(self.cache)
+
+ def get(self, key):
+ retval = None
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ try:
+ f = file(cacheFullPath, "r")
+ retval = f.read()
+ f.close()
+ except IOError:
+ pass
+ return retval
+
+ def set(self, key, value):
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ f = file(cacheFullPath, "w")
+ f.write(value)
+ f.close()
+
+ def delete(self, key):
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ if os.path.exists(cacheFullPath):
+ os.remove(cacheFullPath)
+
+class Credentials(object):
+ def __init__(self):
+ self.credentials = []
+
+ def add(self, name, password, domain=""):
+ self.credentials.append((domain.lower(), name, password))
+
+ def clear(self):
+ self.credentials = []
+
+ def iter(self, domain):
+ for (cdomain, name, password) in self.credentials:
+ if cdomain == "" or domain == cdomain:
+ yield (name, password)
+
+class KeyCerts(Credentials):
+ """Identical to Credentials except that
+ name/password are mapped to key/cert."""
+ pass
+
+
+class ProxyInfo(object):
+ """Collect information required to use a proxy."""
+ def __init__(self, proxy_type, proxy_host, proxy_port, proxy_rdns=None, proxy_user=None, proxy_pass=None):
+ """The parameter proxy_type must be set to one of socks.PROXY_TYPE_XXX
+ constants. For example:
+
+p = ProxyInfo(proxy_type=socks.PROXY_TYPE_HTTP, proxy_host='localhost', proxy_port=8000)
+ """
+ self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, self.proxy_user, self.proxy_pass = proxy_type, proxy_host, proxy_port, proxy_rdns, proxy_user, proxy_pass
+
+ def astuple(self):
+ return (self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns,
+ self.proxy_user, self.proxy_pass)
+
+ def isgood(self):
+ return socks and (self.proxy_host != None) and (self.proxy_port != None)
+
+
+class HTTPConnectionWithTimeout(httplib.HTTPConnection):
+ """HTTPConnection subclass that supports timeouts"""
+
+ def __init__(self, host, port=None, strict=None, timeout=None, proxy_info=None):
+ httplib.HTTPConnection.__init__(self, host, port, strict)
+ self.timeout = timeout
+ self.proxy_info = proxy_info
+
+ def connect(self):
+ """Connect to the host and port specified in __init__."""
+ # Mostly verbatim from httplib.py.
+ msg = "getaddrinfo returns an empty list"
+ for res in socket.getaddrinfo(self.host, self.port, 0,
+ socket.SOCK_STREAM):
+ af, socktype, proto, canonname, sa = res
+ try:
+ if self.proxy_info and self.proxy_info.isgood():
+ self.sock = socks.socksocket(af, socktype, proto)
+ self.sock.setproxy(*self.proxy_info.astuple())
+ else:
+ self.sock = socket.socket(af, socktype, proto)
+ # Different from httplib: support timeouts.
+ if self.timeout is not None:
+ self.sock.settimeout(self.timeout)
+ # End of difference from httplib.
+ if self.debuglevel > 0:
+ print "connect: (%s, %s)" % (self.host, self.port)
+ self.sock.connect(sa)
+ except socket.error, msg:
+ if self.debuglevel > 0:
+ print 'connect fail:', (self.host, self.port)
+ if self.sock:
+ self.sock.close()
+ self.sock = None
+ continue
+ break
+ if not self.sock:
+ raise socket.error, msg
+
+class HTTPSConnectionWithTimeout(httplib.HTTPSConnection):
+ "This class allows communication via SSL."
+
+ def __init__(self, host, port=None, key_file=None, cert_file=None,
+ strict=None, timeout=None, proxy_info=None):
+ self.timeout = timeout
+ self.proxy_info = proxy_info
+ httplib.HTTPSConnection.__init__(self, host, port=port, key_file=key_file,
+ cert_file=cert_file, strict=strict)
+
+ def connect(self):
+ "Connect to a host on a given (SSL) port."
+
+ if self.proxy_info and self.proxy_info.isgood():
+ self.sock.setproxy(*self.proxy_info.astuple())
+ sock.setproxy(*self.proxy_info.astuple())
+ else:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ if self.timeout is not None:
+ sock.settimeout(self.timeout)
+ sock.connect((self.host, self.port))
+ ssl = socket.ssl(sock, self.key_file, self.cert_file)
+ self.sock = httplib.FakeSocket(sock, ssl)
+
+
+
+class Http(object):
+ """An HTTP client that handles:
+- all methods
+- caching
+- ETags
+- compression,
+- HTTPS
+- Basic
+- Digest
+- WSSE
+
+and more.
+ """
+ def __init__(self, cache=None, timeout=None, proxy_info=None):
+ """The value of proxy_info is a ProxyInfo instance.
+
+If 'cache' is a string then it is used as a directory name
+for a disk cache. Otherwise it must be an object that supports
+the same interface as FileCache."""
+ self.proxy_info = proxy_info
+ # Map domain name to an httplib connection
+ self.connections = {}
+ # The location of the cache, for now a directory
+ # where cached responses are held.
+ if cache and isinstance(cache, str):
+ self.cache = FileCache(cache)
+ else:
+ self.cache = cache
+
+ # Name/password
+ self.credentials = Credentials()
+
+ # Key/cert
+ self.certificates = KeyCerts()
+
+ # authorization objects
+ self.authorizations = []
+
+ # If set to False then no redirects are followed, even safe ones.
+ self.follow_redirects = True
+
+ # If 'follow_redirects' is True, and this is set to True then
+ # all redirecs are followed, including unsafe ones.
+ self.follow_all_redirects = False
+
+ self.ignore_etag = False
+
+ self.force_exception_to_status_code = False
+
+ self.timeout = timeout
+
+ def _auth_from_challenge(self, host, request_uri, headers, response, content):
+ """A generator that creates Authorization objects
+ that can be applied to requests.
+ """
+ challenges = _parse_www_authenticate(response, 'www-authenticate')
+ for cred in self.credentials.iter(host):
+ for scheme in AUTH_SCHEME_ORDER:
+ if challenges.has_key(scheme):
+ yield AUTH_SCHEME_CLASSES[scheme](cred, host, request_uri, headers, response, content, self)
+
+ def add_credentials(self, name, password, domain=""):
+ """Add a name and password that will be used
+ any time a request requires authentication."""
+ self.credentials.add(name, password, domain)
+
+ def add_certificate(self, key, cert, domain):
+ """Add a key and cert that will be used
+ any time a request requires authentication."""
+ self.certificates.add(key, cert, domain)
+
+ def clear_credentials(self):
+ """Remove all the names and passwords
+ that are used for authentication"""
+ self.credentials.clear()
+ self.authorizations = []
+
+ def _conn_request(self, conn, request_uri, method, body, headers):
+ for i in range(2):
+ try:
+ conn.request(method, request_uri, body, headers)
+ response = conn.getresponse()
+ except socket.gaierror:
+ conn.close()
+ raise ServerNotFoundError("Unable to find the server at %s" % conn.host)
+ except httplib.HTTPException, e:
+ if i == 0:
+ conn.close()
+ conn.connect()
+ continue
+ else:
+ raise
+ else:
+ content = response.read()
+ response = Response(response)
+ if method != "HEAD":
+ content = _decompressContent(response, content)
+
+ break;
+ return (response, content)
+
+
+ def _request(self, conn, host, absolute_uri, request_uri, method, body, headers, redirections, cachekey):
+ """Do the actual request using the connection object
+ and also follow one level of redirects if necessary"""
+
+ auths = [(auth.depth(request_uri), auth) for auth in self.authorizations if auth.inscope(host, request_uri)]
+ auth = auths and sorted(auths)[0][1] or None
+ if auth:
+ auth.request(method, request_uri, headers, body)
+
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers)
+
+ if auth:
+ if auth.response(response, body):
+ auth.request(method, request_uri, headers, body)
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers )
+ response._stale_digest = 1
+
+ if response.status == 401:
+ for authorization in self._auth_from_challenge(host, request_uri, headers, response, content):
+ authorization.request(method, request_uri, headers, body)
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers, )
+ if response.status != 401:
+ self.authorizations.append(authorization)
+ authorization.response(response, body)
+ break
+
+ if (self.follow_all_redirects or (method in ["GET", "HEAD"]) or response.status == 303):
+ if self.follow_redirects and response.status in [300, 301, 302, 303, 307]:
+ # Pick out the location header and basically start from the beginning
+ # remembering first to strip the ETag header and decrement our 'depth'
+ if redirections:
+ if not response.has_key('location') and response.status != 300:
+ raise RedirectMissingLocation( _("Redirected but the response is missing a Location: header."), response, content)
+ # Fix-up relative redirects (which violate an RFC 2616 MUST)
+ if response.has_key('location'):
+ location = response['location']
+ (scheme, authority, path, query, fragment) = parse_uri(location)
+ if authority == None:
+ response['location'] = urlparse.urljoin(absolute_uri, location)
+ if response.status == 301 and method in ["GET", "HEAD"]:
+ response['-x-permanent-redirect-url'] = response['location']
+ if not response.has_key('content-location'):
+ response['content-location'] = absolute_uri
+ _updateCache(headers, response, content, self.cache, cachekey)
+ if headers.has_key('if-none-match'):
+ del headers['if-none-match']
+ if headers.has_key('if-modified-since'):
+ del headers['if-modified-since']
+ if response.has_key('location'):
+ location = response['location']
+ old_response = copy.deepcopy(response)
+ if not old_response.has_key('content-location'):
+ old_response['content-location'] = absolute_uri
+ redirect_method = ((response.status == 303) and (method not in ["GET", "HEAD"])) and "GET" or method
+ (response, content) = self.request(location, redirect_method, body=body, headers = headers, redirections = redirections - 1)
+ response.previous = old_response
+ else:
+ raise RedirectLimit( _("Redirected more times than rediection_limit allows."), response, content)
+ elif response.status in [200, 203] and method == "GET":
+ # Don't cache 206's since we aren't going to handle byte range requests
+ if not response.has_key('content-location'):
+ response['content-location'] = absolute_uri
+ _updateCache(headers, response, content, self.cache, cachekey)
+
+ return (response, content)
+
+
+# Need to catch and rebrand some exceptions
+# Then need to optionally turn all exceptions into status codes
+# including all socket.* and httplib.* exceptions.
+
+
+ def request(self, uri, method="GET", body=None, headers=None, redirections=DEFAULT_MAX_REDIRECTS, connection_type=None):
+ """ Performs a single HTTP request.
+The 'uri' is the URI of the HTTP resource and can begin
+with either 'http' or 'https'. The value of 'uri' must be an absolute URI.
+
+The 'method' is the HTTP method to perform, such as GET, POST, DELETE, etc.
+There is no restriction on the methods allowed.
+
+The 'body' is the entity body to be sent with the request. It is a string
+object.
+
+Any extra headers that are to be sent with the request should be provided in the
+'headers' dictionary.
+
+The maximum number of redirect to follow before raising an
+exception is 'redirections. The default is 5.
+
+The return value is a tuple of (response, content), the first
+being and instance of the 'Response' class, the second being
+a string that contains the response entity body.
+ """
+ try:
+ if headers is None:
+ headers = {}
+ else:
+ headers = _normalize_headers(headers)
+
+ if not headers.has_key('user-agent'):
+ headers['user-agent'] = "Python-httplib2/%s" % __version__
+
+ uri = iri2uri(uri)
+
+ (scheme, authority, request_uri, defrag_uri) = urlnorm(uri)
+
+ conn_key = scheme+":"+authority
+ if conn_key in self.connections:
+ conn = self.connections[conn_key]
+ else:
+ if not connection_type:
+ connection_type = (scheme == 'https') and HTTPSConnectionWithTimeout or HTTPConnectionWithTimeout
+ certs = list(self.certificates.iter(authority))
+ if scheme == 'https' and certs:
+ conn = self.connections[conn_key] = connection_type(authority, key_file=certs[0][0],
+ cert_file=certs[0][1], timeout=self.timeout, proxy_info=self.proxy_info)
+ else:
+ conn = self.connections[conn_key] = connection_type(authority, timeout=self.timeout, proxy_info=self.proxy_info)
+ conn.set_debuglevel(debuglevel)
+
+ if method in ["GET", "HEAD"] and 'range' not in headers:
+ headers['accept-encoding'] = 'compress, gzip'
+
+ info = email.Message.Message()
+ cached_value = None
+ if self.cache:
+ cachekey = defrag_uri
+ cached_value = self.cache.get(cachekey)
+ if cached_value:
+ info = email.message_from_string(cached_value)
+ try:
+ content = cached_value.split('\r\n\r\n', 1)[1]
+ except IndexError:
+ self.cache.delete(cachekey)
+ cachekey = None
+ cached_value = None
+ else:
+ cachekey = None
+
+ if method in ["PUT"] and self.cache and info.has_key('etag') and not self.ignore_etag and 'if-match' not in headers:
+ # http://www.w3.org/1999/04/Editing/
+ headers['if-match'] = info['etag']
+
+ if method not in ["GET", "HEAD"] and self.cache and cachekey:
+ # RFC 2616 Section 13.10
+ self.cache.delete(cachekey)
+
+ if cached_value and method in ["GET", "HEAD"] and self.cache and 'range' not in headers:
+ if info.has_key('-x-permanent-redirect-url'):
+ # Should cached permanent redirects be counted in our redirection count? For now, yes.
+ (response, new_content) = self.request(info['-x-permanent-redirect-url'], "GET", headers = headers, redirections = redirections - 1)
+ response.previous = Response(info)
+ response.previous.fromcache = True
+ else:
+ # Determine our course of action:
+ # Is the cached entry fresh or stale?
+ # Has the client requested a non-cached response?
+ #
+ # There seems to be three possible answers:
+ # 1. [FRESH] Return the cache entry w/o doing a GET
+ # 2. [STALE] Do the GET (but add in cache validators if available)
+ # 3. [TRANSPARENT] Do a GET w/o any cache validators (Cache-Control: no-cache) on the request
+ entry_disposition = _entry_disposition(info, headers)
+
+ if entry_disposition == "FRESH":
+ if not cached_value:
+ info['status'] = '504'
+ content = ""
+ response = Response(info)
+ if cached_value:
+ response.fromcache = True
+ return (response, content)
+
+ if entry_disposition == "STALE":
+ if info.has_key('etag') and not self.ignore_etag and not 'if-none-match' in headers:
+ headers['if-none-match'] = info['etag']
+ if info.has_key('last-modified') and not 'last-modified' in headers:
+ headers['if-modified-since'] = info['last-modified']
+ elif entry_disposition == "TRANSPARENT":
+ pass
+
+ (response, new_content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
+
+ if response.status == 304 and method == "GET":
+ # Rewrite the cache entry with the new end-to-end headers
+ # Take all headers that are in response
+ # and overwrite their values in info.
+ # unless they are hop-by-hop, or are listed in the connection header.
+
+ for key in _get_end2end_headers(response):
+ info[key] = response[key]
+ merged_response = Response(info)
+ if hasattr(response, "_stale_digest"):
+ merged_response._stale_digest = response._stale_digest
+ _updateCache(headers, merged_response, content, self.cache, cachekey)
+ response = merged_response
+ response.status = 200
+ response.fromcache = True
+
+ elif response.status == 200:
+ content = new_content
+ else:
+ self.cache.delete(cachekey)
+ content = new_content
+ else:
+ (response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
+ except Exception, e:
+ if self.force_exception_to_status_code:
+ if isinstance(e, HttpLib2ErrorWithResponse):
+ response = e.response
+ content = e.content
+ response.status = 500
+ response.reason = str(e)
+ elif isinstance(e, socket.timeout):
+ content = "Request Timeout"
+ response = Response( {
+ "content-type": "text/plain",
+ "status": "408",
+ "content-length": len(content)
+ })
+ response.reason = "Request Timeout"
+ else:
+ content = str(e)
+ response = Response( {
+ "content-type": "text/plain",
+ "status": "400",
+ "content-length": len(content)
+ })
+ response.reason = "Bad Request"
+ else:
+ raise
+
+
+ return (response, content)
+
+
+
+class Response(dict):
+ """An object more like email.Message than httplib.HTTPResponse."""
+
+ """Is this response from our local cache"""
+ fromcache = False
+
+ """HTTP protocol version used by server. 10 for HTTP/1.0, 11 for HTTP/1.1. """
+ version = 11
+
+ "Status code returned by server. "
+ status = 200
+
+ """Reason phrase returned by server."""
+ reason = "Ok"
+
+ previous = None
+
+ def __init__(self, info):
+ # info is either an email.Message or
+ # an httplib.HTTPResponse object.
+ if isinstance(info, httplib.HTTPResponse):
+ for key, value in info.getheaders():
+ self[key] = value
+ self.status = info.status
+ self['status'] = str(self.status)
+ self.reason = info.reason
+ self.version = info.version
+ elif isinstance(info, email.Message.Message):
+ for key, value in info.items():
+ self[key] = value
+ self.status = int(self['status'])
+ else:
+ for key, value in info.iteritems():
+ self[key] = value
+ self.status = int(self.get('status', self.status))
+
+
+ def __getattr__(self, name):
+ if name == 'dict':
+ return self
+ else:
+ raise AttributeError, name
diff --git a/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base b/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base
new file mode 100644
index 0000000..f092204
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/text-base/__init__.pyc.svn-base
Binary files differ
diff --git a/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base
new file mode 100644
index 0000000..70667ed
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.py.svn-base
@@ -0,0 +1,110 @@
+"""
+iri2uri
+
+Converts an IRI to a URI.
+
+"""
+__author__ = "Joe Gregorio (joe@bitworking.org)"
+__copyright__ = "Copyright 2006, Joe Gregorio"
+__contributors__ = []
+__version__ = "1.0.0"
+__license__ = "MIT"
+__history__ = """
+"""
+
+import urlparse
+
+
+# Convert an IRI to a URI following the rules in RFC 3987
+#
+# The characters we need to enocde and escape are defined in the spec:
+#
+# iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD
+# ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF
+# / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD
+# / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD
+# / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD
+# / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD
+# / %xD0000-DFFFD / %xE1000-EFFFD
+
+escape_range = [
+ (0xA0, 0xD7FF ),
+ (0xE000, 0xF8FF ),
+ (0xF900, 0xFDCF ),
+ (0xFDF0, 0xFFEF),
+ (0x10000, 0x1FFFD ),
+ (0x20000, 0x2FFFD ),
+ (0x30000, 0x3FFFD),
+ (0x40000, 0x4FFFD ),
+ (0x50000, 0x5FFFD ),
+ (0x60000, 0x6FFFD),
+ (0x70000, 0x7FFFD ),
+ (0x80000, 0x8FFFD ),
+ (0x90000, 0x9FFFD),
+ (0xA0000, 0xAFFFD ),
+ (0xB0000, 0xBFFFD ),
+ (0xC0000, 0xCFFFD),
+ (0xD0000, 0xDFFFD ),
+ (0xE1000, 0xEFFFD),
+ (0xF0000, 0xFFFFD ),
+ (0x100000, 0x10FFFD)
+]
+
+def encode(c):
+ retval = c
+ i = ord(c)
+ for low, high in escape_range:
+ if i < low:
+ break
+ if i >= low and i <= high:
+ retval = "".join(["%%%2X" % ord(o) for o in c.encode('utf-8')])
+ break
+ return retval
+
+
+def iri2uri(uri):
+ """Convert an IRI to a URI. Note that IRIs must be
+ passed in a unicode strings. That is, do not utf-8 encode
+ the IRI before passing it into the function."""
+ if isinstance(uri ,unicode):
+ (scheme, authority, path, query, fragment) = urlparse.urlsplit(uri)
+ authority = authority.encode('idna')
+ # For each character in 'ucschar' or 'iprivate'
+ # 1. encode as utf-8
+ # 2. then %-encode each octet of that utf-8
+ uri = urlparse.urlunsplit((scheme, authority, path, query, fragment))
+ uri = "".join([encode(c) for c in uri])
+ return uri
+
+if __name__ == "__main__":
+ import unittest
+
+ class Test(unittest.TestCase):
+
+ def test_uris(self):
+ """Test that URIs are invariant under the transformation."""
+ invariant = [
+ u"ftp://ftp.is.co.za/rfc/rfc1808.txt",
+ u"http://www.ietf.org/rfc/rfc2396.txt",
+ u"ldap://[2001:db8::7]/c=GB?objectClass?one",
+ u"mailto:John.Doe@example.com",
+ u"news:comp.infosystems.www.servers.unix",
+ u"tel:+1-816-555-1212",
+ u"telnet://192.0.2.16:80/",
+ u"urn:oasis:names:specification:docbook:dtd:xml:4.1.2" ]
+ for uri in invariant:
+ self.assertEqual(uri, iri2uri(uri))
+
+ def test_iri(self):
+ """ Test that the right type of escaping is done for each part of the URI."""
+ self.assertEqual("http://xn--o3h.com/%E2%98%84", iri2uri(u"http://\N{COMET}.com/\N{COMET}"))
+ self.assertEqual("http://bitworking.org/?fred=%E2%98%84", iri2uri(u"http://bitworking.org/?fred=\N{COMET}"))
+ self.assertEqual("http://bitworking.org/#%E2%98%84", iri2uri(u"http://bitworking.org/#\N{COMET}"))
+ self.assertEqual("#%E2%98%84", iri2uri(u"#\N{COMET}"))
+ self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}"))
+ self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}")))
+ self.assertNotEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}".encode('utf-8')))
+
+ unittest.main()
+
+
diff --git a/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base
new file mode 100644
index 0000000..e16a3db
--- /dev/null
+++ b/tutorius/apilib/httplib2/.svn/text-base/iri2uri.pyc.svn-base
Binary files differ
diff --git a/tutorius/apilib/httplib2/__init__.py b/tutorius/apilib/httplib2/__init__.py
new file mode 100644
index 0000000..982bf8a
--- /dev/null
+++ b/tutorius/apilib/httplib2/__init__.py
@@ -0,0 +1,1123 @@
+from __future__ import generators
+"""
+httplib2
+
+A caching http interface that supports ETags and gzip
+to conserve bandwidth.
+
+Requires Python 2.3 or later
+
+Changelog:
+2007-08-18, Rick: Modified so it's able to use a socks proxy if needed.
+
+"""
+
+__author__ = "Joe Gregorio (joe@bitworking.org)"
+__copyright__ = "Copyright 2006, Joe Gregorio"
+__contributors__ = ["Thomas Broyer (t.broyer@ltgt.net)",
+ "James Antill",
+ "Xavier Verges Farrero",
+ "Jonathan Feinberg",
+ "Blair Zajac",
+ "Sam Ruby",
+ "Louis Nyffenegger"]
+__license__ = "MIT"
+__version__ = "$Rev: 259 $"
+
+import re
+import sys
+import md5
+import email
+import email.Utils
+import email.Message
+import StringIO
+import gzip
+import zlib
+import httplib
+import urlparse
+import base64
+import os
+import copy
+import calendar
+import time
+import random
+import sha
+import hmac
+from gettext import gettext as _
+import socket
+
+try:
+ import socks
+except ImportError:
+ socks = None
+
+if sys.version_info >= (2,3):
+ from iri2uri import iri2uri
+else:
+ def iri2uri(uri):
+ return uri
+
+__all__ = ['Http', 'Response', 'ProxyInfo', 'HttpLib2Error',
+ 'RedirectMissingLocation', 'RedirectLimit', 'FailedToDecompressContent',
+ 'UnimplementedDigestAuthOptionError', 'UnimplementedHmacDigestAuthOptionError',
+ 'debuglevel']
+
+
+# The httplib debug level, set to a non-zero value to get debug output
+debuglevel = 0
+
+# Python 2.3 support
+if sys.version_info < (2,4):
+ def sorted(seq):
+ seq.sort()
+ return seq
+
+# Python 2.3 support
+def HTTPResponse__getheaders(self):
+ """Return list of (header, value) tuples."""
+ if self.msg is None:
+ raise httplib.ResponseNotReady()
+ return self.msg.items()
+
+if not hasattr(httplib.HTTPResponse, 'getheaders'):
+ httplib.HTTPResponse.getheaders = HTTPResponse__getheaders
+
+# All exceptions raised here derive from HttpLib2Error
+class HttpLib2Error(Exception): pass
+
+# Some exceptions can be caught and optionally
+# be turned back into responses.
+class HttpLib2ErrorWithResponse(HttpLib2Error):
+ def __init__(self, desc, response, content):
+ self.response = response
+ self.content = content
+ HttpLib2Error.__init__(self, desc)
+
+class RedirectMissingLocation(HttpLib2ErrorWithResponse): pass
+class RedirectLimit(HttpLib2ErrorWithResponse): pass
+class FailedToDecompressContent(HttpLib2ErrorWithResponse): pass
+class UnimplementedDigestAuthOptionError(HttpLib2ErrorWithResponse): pass
+class UnimplementedHmacDigestAuthOptionError(HttpLib2ErrorWithResponse): pass
+
+class RelativeURIError(HttpLib2Error): pass
+class ServerNotFoundError(HttpLib2Error): pass
+
+# Open Items:
+# -----------
+# Proxy support
+
+# Are we removing the cached content too soon on PUT (only delete on 200 Maybe?)
+
+# Pluggable cache storage (supports storing the cache in
+# flat files by default. We need a plug-in architecture
+# that can support Berkeley DB and Squid)
+
+# == Known Issues ==
+# Does not handle a resource that uses conneg and Last-Modified but no ETag as a cache validator.
+# Does not handle Cache-Control: max-stale
+# Does not use Age: headers when calculating cache freshness.
+
+
+# The number of redirections to follow before giving up.
+# Note that only GET redirects are automatically followed.
+# Will also honor 301 requests by saving that info and never
+# requesting that URI again.
+DEFAULT_MAX_REDIRECTS = 5
+
+# Which headers are hop-by-hop headers by default
+HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade']
+
+def _get_end2end_headers(response):
+ hopbyhop = list(HOP_BY_HOP)
+ hopbyhop.extend([x.strip() for x in response.get('connection', '').split(',')])
+ return [header for header in response.keys() if header not in hopbyhop]
+
+URI = re.compile(r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?")
+
+def parse_uri(uri):
+ """Parses a URI using the regex given in Appendix B of RFC 3986.
+
+ (scheme, authority, path, query, fragment) = parse_uri(uri)
+ """
+ groups = URI.match(uri).groups()
+ return (groups[1], groups[3], groups[4], groups[6], groups[8])
+
+def urlnorm(uri):
+ (scheme, authority, path, query, fragment) = parse_uri(uri)
+ if not scheme or not authority:
+ raise RelativeURIError("Only absolute URIs are allowed. uri = %s" % uri)
+ authority = authority.lower()
+ scheme = scheme.lower()
+ if not path:
+ path = "/"
+ # Could do syntax based normalization of the URI before
+ # computing the digest. See Section 6.2.2 of Std 66.
+ request_uri = query and "?".join([path, query]) or path
+ scheme = scheme.lower()
+ defrag_uri = scheme + "://" + authority + request_uri
+ return scheme, authority, request_uri, defrag_uri
+
+
+# Cache filename construction (original borrowed from Venus http://intertwingly.net/code/venus/)
+re_url_scheme = re.compile(r'^\w+://')
+re_slash = re.compile(r'[?/:|]+')
+
+def safename(filename):
+ """Return a filename suitable for the cache.
+
+ Strips dangerous and common characters to create a filename we
+ can use to store the cache in.
+ """
+
+ try:
+ if re_url_scheme.match(filename):
+ if isinstance(filename,str):
+ filename = filename.decode('utf-8')
+ filename = filename.encode('idna')
+ else:
+ filename = filename.encode('idna')
+ except UnicodeError:
+ pass
+ if isinstance(filename,unicode):
+ filename=filename.encode('utf-8')
+ filemd5 = md5.new(filename).hexdigest()
+ filename = re_url_scheme.sub("", filename)
+ filename = re_slash.sub(",", filename)
+
+ # limit length of filename
+ if len(filename)>200:
+ filename=filename[:200]
+ return ",".join((filename, filemd5))
+
+NORMALIZE_SPACE = re.compile(r'(?:\r\n)?[ \t]+')
+def _normalize_headers(headers):
+ return dict([ (key.lower(), NORMALIZE_SPACE.sub(value, ' ').strip()) for (key, value) in headers.iteritems()])
+
+def _parse_cache_control(headers):
+ retval = {}
+ if headers.has_key('cache-control'):
+ parts = headers['cache-control'].split(',')
+ parts_with_args = [tuple([x.strip() for x in part.split("=")]) for part in parts if -1 != part.find("=")]
+ parts_wo_args = [(name.strip(), 1) for name in parts if -1 == name.find("=")]
+ retval = dict(parts_with_args + parts_wo_args)
+ return retval
+
+# Whether to use a strict mode to parse WWW-Authenticate headers
+# Might lead to bad results in case of ill-formed header value,
+# so disabled by default, falling back to relaxed parsing.
+# Set to true to turn on, usefull for testing servers.
+USE_WWW_AUTH_STRICT_PARSING = 0
+
+# In regex below:
+# [^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+ matches a "token" as defined by HTTP
+# "(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?" matches a "quoted-string" as defined by HTTP, when LWS have already been replaced by a single space
+# Actually, as an auth-param value can be either a token or a quoted-string, they are combined in a single pattern which matches both:
+# \"?((?<=\")(?:[^\0-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x08\x0A-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?
+WWW_AUTH_STRICT = re.compile(r"^(?:\s*(?:,\s*)?([^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+)\s*=\s*\"?((?<=\")(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?)(.*)$")
+WWW_AUTH_RELAXED = re.compile(r"^(?:\s*(?:,\s*)?([^ \t\r\n=]+)\s*=\s*\"?((?<=\")(?:[^\\\"]|\\.)*?(?=\")|(?<!\")[^ \t\r\n,]+(?!\"))\"?)(.*)$")
+UNQUOTE_PAIRS = re.compile(r'\\(.)')
+def _parse_www_authenticate(headers, headername='www-authenticate'):
+ """Returns a dictionary of dictionaries, one dict
+ per auth_scheme."""
+ retval = {}
+ if headers.has_key(headername):
+ authenticate = headers[headername].strip()
+ www_auth = USE_WWW_AUTH_STRICT_PARSING and WWW_AUTH_STRICT or WWW_AUTH_RELAXED
+ while authenticate:
+ # Break off the scheme at the beginning of the line
+ if headername == 'authentication-info':
+ (auth_scheme, the_rest) = ('digest', authenticate)
+ else:
+ (auth_scheme, the_rest) = authenticate.split(" ", 1)
+ # Now loop over all the key value pairs that come after the scheme,
+ # being careful not to roll into the next scheme
+ match = www_auth.search(the_rest)
+ auth_params = {}
+ while match:
+ if match and len(match.groups()) == 3:
+ (key, value, the_rest) = match.groups()
+ auth_params[key.lower()] = UNQUOTE_PAIRS.sub(r'\1', value) # '\\'.join([x.replace('\\', '') for x in value.split('\\\\')])
+ match = www_auth.search(the_rest)
+ retval[auth_scheme.lower()] = auth_params
+ authenticate = the_rest.strip()
+ return retval
+
+
+def _entry_disposition(response_headers, request_headers):
+ """Determine freshness from the Date, Expires and Cache-Control headers.
+
+ We don't handle the following:
+
+ 1. Cache-Control: max-stale
+ 2. Age: headers are not used in the calculations.
+
+ Not that this algorithm is simpler than you might think
+ because we are operating as a private (non-shared) cache.
+ This lets us ignore 's-maxage'. We can also ignore
+ 'proxy-invalidate' since we aren't a proxy.
+ We will never return a stale document as
+ fresh as a design decision, and thus the non-implementation
+ of 'max-stale'. This also lets us safely ignore 'must-revalidate'
+ since we operate as if every server has sent 'must-revalidate'.
+ Since we are private we get to ignore both 'public' and
+ 'private' parameters. We also ignore 'no-transform' since
+ we don't do any transformations.
+ The 'no-store' parameter is handled at a higher level.
+ So the only Cache-Control parameters we look at are:
+
+ no-cache
+ only-if-cached
+ max-age
+ min-fresh
+ """
+
+ retval = "STALE"
+ cc = _parse_cache_control(request_headers)
+ cc_response = _parse_cache_control(response_headers)
+
+ if request_headers.has_key('pragma') and request_headers['pragma'].lower().find('no-cache') != -1:
+ retval = "TRANSPARENT"
+ if 'cache-control' not in request_headers:
+ request_headers['cache-control'] = 'no-cache'
+ elif cc.has_key('no-cache'):
+ retval = "TRANSPARENT"
+ elif cc_response.has_key('no-cache'):
+ retval = "STALE"
+ elif cc.has_key('only-if-cached'):
+ retval = "FRESH"
+ elif response_headers.has_key('date'):
+ date = calendar.timegm(email.Utils.parsedate_tz(response_headers['date']))
+ now = time.time()
+ current_age = max(0, now - date)
+ if cc_response.has_key('max-age'):
+ try:
+ freshness_lifetime = int(cc_response['max-age'])
+ except ValueError:
+ freshness_lifetime = 0
+ elif response_headers.has_key('expires'):
+ expires = email.Utils.parsedate_tz(response_headers['expires'])
+ if None == expires:
+ freshness_lifetime = 0
+ else:
+ freshness_lifetime = max(0, calendar.timegm(expires) - date)
+ else:
+ freshness_lifetime = 0
+ if cc.has_key('max-age'):
+ try:
+ freshness_lifetime = int(cc['max-age'])
+ except ValueError:
+ freshness_lifetime = 0
+ if cc.has_key('min-fresh'):
+ try:
+ min_fresh = int(cc['min-fresh'])
+ except ValueError:
+ min_fresh = 0
+ current_age += min_fresh
+ if freshness_lifetime > current_age:
+ retval = "FRESH"
+ return retval
+
+def _decompressContent(response, new_content):
+ content = new_content
+ try:
+ encoding = response.get('content-encoding', None)
+ if encoding in ['gzip', 'deflate']:
+ if encoding == 'gzip':
+ content = gzip.GzipFile(fileobj=StringIO.StringIO(new_content)).read()
+ if encoding == 'deflate':
+ content = zlib.decompress(content)
+ response['content-length'] = str(len(content))
+ del response['content-encoding']
+ except IOError:
+ content = ""
+ raise FailedToDecompressContent(_("Content purported to be compressed with %s but failed to decompress.") % response.get('content-encoding'), response, content)
+ return content
+
+def _updateCache(request_headers, response_headers, content, cache, cachekey):
+ if cachekey:
+ cc = _parse_cache_control(request_headers)
+ cc_response = _parse_cache_control(response_headers)
+ if cc.has_key('no-store') or cc_response.has_key('no-store'):
+ cache.delete(cachekey)
+ else:
+ info = email.Message.Message()
+ for key, value in response_headers.iteritems():
+ if key not in ['status','content-encoding','transfer-encoding']:
+ info[key] = value
+
+ status = response_headers.status
+ if status == 304:
+ status = 200
+
+ status_header = 'status: %d\r\n' % response_headers.status
+
+ header_str = info.as_string()
+
+ header_str = re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", header_str)
+ text = "".join([status_header, header_str, content])
+
+ cache.set(cachekey, text)
+
+def _cnonce():
+ dig = md5.new("%s:%s" % (time.ctime(), ["0123456789"[random.randrange(0, 9)] for i in range(20)])).hexdigest()
+ return dig[:16]
+
+def _wsse_username_token(cnonce, iso_now, password):
+ return base64.encodestring(sha.new("%s%s%s" % (cnonce, iso_now, password)).digest()).strip()
+
+
+# For credentials we need two things, first
+# a pool of credential to try (not necesarily tied to BAsic, Digest, etc.)
+# Then we also need a list of URIs that have already demanded authentication
+# That list is tricky since sub-URIs can take the same auth, or the
+# auth scheme may change as you descend the tree.
+# So we also need each Auth instance to be able to tell us
+# how close to the 'top' it is.
+
+class Authentication(object):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ self.path = path
+ self.host = host
+ self.credentials = credentials
+ self.http = http
+
+ def depth(self, request_uri):
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ return request_uri[len(self.path):].count("/")
+
+ def inscope(self, host, request_uri):
+ # XXX Should we normalize the request_uri?
+ (scheme, authority, path, query, fragment) = parse_uri(request_uri)
+ return (host == self.host) and path.startswith(self.path)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header. Over-rise this in sub-classes."""
+ pass
+
+ def response(self, response, content):
+ """Gives us a chance to update with new nonces
+ or such returned from the last authorized response.
+ Over-rise this in sub-classes if necessary.
+
+ Return TRUE is the request is to be retried, for
+ example Digest may return stale=true.
+ """
+ return False
+
+
+
+class BasicAuthentication(Authentication):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['authorization'] = 'Basic ' + base64.encodestring("%s:%s" % self.credentials).strip()
+
+
+class DigestAuthentication(Authentication):
+ """Only do qop='auth' and MD5, since that
+ is all Apache currently implements"""
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ self.challenge = challenge['digest']
+ qop = self.challenge.get('qop')
+ self.challenge['qop'] = ('auth' in [x.strip() for x in qop.split()]) and 'auth' or None
+ if self.challenge['qop'] is None:
+ raise UnimplementedDigestAuthOptionError( _("Unsupported value for qop: %s." % qop))
+ self.challenge['algorithm'] = self.challenge.get('algorithm', 'MD5')
+ if self.challenge['algorithm'] != 'MD5':
+ raise UnimplementedDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm']))
+ self.A1 = "".join([self.credentials[0], ":", self.challenge['realm'], ":", self.credentials[1]])
+ self.challenge['nc'] = 1
+
+ def request(self, method, request_uri, headers, content, cnonce = None):
+ """Modify the request headers"""
+ H = lambda x: md5.new(x).hexdigest()
+ KD = lambda s, d: H("%s:%s" % (s, d))
+ A2 = "".join([method, ":", request_uri])
+ self.challenge['cnonce'] = cnonce or _cnonce()
+ request_digest = '"%s"' % KD(H(self.A1), "%s:%s:%s:%s:%s" % (self.challenge['nonce'],
+ '%08x' % self.challenge['nc'],
+ self.challenge['cnonce'],
+ self.challenge['qop'], H(A2)
+ ))
+ headers['Authorization'] = 'Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm=%s, response=%s, qop=%s, nc=%08x, cnonce="%s"' % (
+ self.credentials[0],
+ self.challenge['realm'],
+ self.challenge['nonce'],
+ request_uri,
+ self.challenge['algorithm'],
+ request_digest,
+ self.challenge['qop'],
+ self.challenge['nc'],
+ self.challenge['cnonce'],
+ )
+ self.challenge['nc'] += 1
+
+ def response(self, response, content):
+ if not response.has_key('authentication-info'):
+ challenge = _parse_www_authenticate(response, 'www-authenticate').get('digest', {})
+ if 'true' == challenge.get('stale'):
+ self.challenge['nonce'] = challenge['nonce']
+ self.challenge['nc'] = 1
+ return True
+ else:
+ updated_challenge = _parse_www_authenticate(response, 'authentication-info').get('digest', {})
+
+ if updated_challenge.has_key('nextnonce'):
+ self.challenge['nonce'] = updated_challenge['nextnonce']
+ self.challenge['nc'] = 1
+ return False
+
+
+class HmacDigestAuthentication(Authentication):
+ """Adapted from Robert Sayre's code and DigestAuthentication above."""
+ __author__ = "Thomas Broyer (t.broyer@ltgt.net)"
+
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ self.challenge = challenge['hmacdigest']
+ # TODO: self.challenge['domain']
+ self.challenge['reason'] = self.challenge.get('reason', 'unauthorized')
+ if self.challenge['reason'] not in ['unauthorized', 'integrity']:
+ self.challenge['reason'] = 'unauthorized'
+ self.challenge['salt'] = self.challenge.get('salt', '')
+ if not self.challenge.get('snonce'):
+ raise UnimplementedHmacDigestAuthOptionError( _("The challenge doesn't contain a server nonce, or this one is empty."))
+ self.challenge['algorithm'] = self.challenge.get('algorithm', 'HMAC-SHA-1')
+ if self.challenge['algorithm'] not in ['HMAC-SHA-1', 'HMAC-MD5']:
+ raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm']))
+ self.challenge['pw-algorithm'] = self.challenge.get('pw-algorithm', 'SHA-1')
+ if self.challenge['pw-algorithm'] not in ['SHA-1', 'MD5']:
+ raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for pw-algorithm: %s." % self.challenge['pw-algorithm']))
+ if self.challenge['algorithm'] == 'HMAC-MD5':
+ self.hashmod = md5
+ else:
+ self.hashmod = sha
+ if self.challenge['pw-algorithm'] == 'MD5':
+ self.pwhashmod = md5
+ else:
+ self.pwhashmod = sha
+ self.key = "".join([self.credentials[0], ":",
+ self.pwhashmod.new("".join([self.credentials[1], self.challenge['salt']])).hexdigest().lower(),
+ ":", self.challenge['realm']
+ ])
+ self.key = self.pwhashmod.new(self.key).hexdigest().lower()
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers"""
+ keys = _get_end2end_headers(headers)
+ keylist = "".join(["%s " % k for k in keys])
+ headers_val = "".join([headers[k] for k in keys])
+ created = time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
+ cnonce = _cnonce()
+ request_digest = "%s:%s:%s:%s:%s" % (method, request_uri, cnonce, self.challenge['snonce'], headers_val)
+ request_digest = hmac.new(self.key, request_digest, self.hashmod).hexdigest().lower()
+ headers['Authorization'] = 'HMACDigest username="%s", realm="%s", snonce="%s", cnonce="%s", uri="%s", created="%s", response="%s", headers="%s"' % (
+ self.credentials[0],
+ self.challenge['realm'],
+ self.challenge['snonce'],
+ cnonce,
+ request_uri,
+ created,
+ request_digest,
+ keylist,
+ )
+
+ def response(self, response, content):
+ challenge = _parse_www_authenticate(response, 'www-authenticate').get('hmacdigest', {})
+ if challenge.get('reason') in ['integrity', 'stale']:
+ return True
+ return False
+
+
+class WsseAuthentication(Authentication):
+ """This is thinly tested and should not be relied upon.
+ At this time there isn't any third party server to test against.
+ Blogger and TypePad implemented this algorithm at one point
+ but Blogger has since switched to Basic over HTTPS and
+ TypePad has implemented it wrong, by never issuing a 401
+ challenge but instead requiring your client to telepathically know that
+ their endpoint is expecting WSSE profile="UsernameToken"."""
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['Authorization'] = 'WSSE profile="UsernameToken"'
+ iso_now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ cnonce = _cnonce()
+ password_digest = _wsse_username_token(cnonce, iso_now, self.credentials[1])
+ headers['X-WSSE'] = 'UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s"' % (
+ self.credentials[0],
+ password_digest,
+ cnonce,
+ iso_now)
+
+class GoogleLoginAuthentication(Authentication):
+ def __init__(self, credentials, host, request_uri, headers, response, content, http):
+ from urllib import urlencode
+ Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http)
+ challenge = _parse_www_authenticate(response, 'www-authenticate')
+ service = challenge['googlelogin'].get('service', 'xapi')
+ # Bloggger actually returns the service in the challenge
+ # For the rest we guess based on the URI
+ if service == 'xapi' and request_uri.find("calendar") > 0:
+ service = "cl"
+ # No point in guessing Base or Spreadsheet
+ #elif request_uri.find("spreadsheets") > 0:
+ # service = "wise"
+
+ auth = dict(Email=credentials[0], Passwd=credentials[1], service=service, source=headers['user-agent'])
+ resp, content = self.http.request("https://www.google.com/accounts/ClientLogin", method="POST", body=urlencode(auth), headers={'Content-Type': 'application/x-www-form-urlencoded'})
+ lines = content.split('\n')
+ d = dict([tuple(line.split("=", 1)) for line in lines if line])
+ if resp.status == 403:
+ self.Auth = ""
+ else:
+ self.Auth = d['Auth']
+
+ def request(self, method, request_uri, headers, content):
+ """Modify the request headers to add the appropriate
+ Authorization header."""
+ headers['authorization'] = 'GoogleLogin Auth=' + self.Auth
+
+
+AUTH_SCHEME_CLASSES = {
+ "basic": BasicAuthentication,
+ "wsse": WsseAuthentication,
+ "digest": DigestAuthentication,
+ "hmacdigest": HmacDigestAuthentication,
+ "googlelogin": GoogleLoginAuthentication
+}
+
+AUTH_SCHEME_ORDER = ["hmacdigest", "googlelogin", "digest", "wsse", "basic"]
+
+def _md5(s):
+ return
+
+class FileCache(object):
+ """Uses a local directory as a store for cached files.
+ Not really safe to use if multiple threads or processes are going to
+ be running on the same cache.
+ """
+ def __init__(self, cache, safe=safename): # use safe=lambda x: md5.new(x).hexdigest() for the old behavior
+ self.cache = cache
+ self.safe = safe
+ if not os.path.exists(cache):
+ os.makedirs(self.cache)
+
+ def get(self, key):
+ retval = None
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ try:
+ f = file(cacheFullPath, "r")
+ retval = f.read()
+ f.close()
+ except IOError:
+ pass
+ return retval
+
+ def set(self, key, value):
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ f = file(cacheFullPath, "w")
+ f.write(value)
+ f.close()
+
+ def delete(self, key):
+ cacheFullPath = os.path.join(self.cache, self.safe(key))
+ if os.path.exists(cacheFullPath):
+ os.remove(cacheFullPath)
+
+class Credentials(object):
+ def __init__(self):
+ self.credentials = []
+
+ def add(self, name, password, domain=""):
+ self.credentials.append((domain.lower(), name, password))
+
+ def clear(self):
+ self.credentials = []
+
+ def iter(self, domain):
+ for (cdomain, name, password) in self.credentials:
+ if cdomain == "" or domain == cdomain:
+ yield (name, password)
+
+class KeyCerts(Credentials):
+ """Identical to Credentials except that
+ name/password are mapped to key/cert."""
+ pass
+
+
+class ProxyInfo(object):
+ """Collect information required to use a proxy."""
+ def __init__(self, proxy_type, proxy_host, proxy_port, proxy_rdns=None, proxy_user=None, proxy_pass=None):
+ """The parameter proxy_type must be set to one of socks.PROXY_TYPE_XXX
+ constants. For example:
+
+p = ProxyInfo(proxy_type=socks.PROXY_TYPE_HTTP, proxy_host='localhost', proxy_port=8000)
+ """
+ self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, self.proxy_user, self.proxy_pass = proxy_type, proxy_host, proxy_port, proxy_rdns, proxy_user, proxy_pass
+
+ def astuple(self):
+ return (self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns,
+ self.proxy_user, self.proxy_pass)
+
+ def isgood(self):
+ return socks and (self.proxy_host != None) and (self.proxy_port != None)
+
+
+class HTTPConnectionWithTimeout(httplib.HTTPConnection):
+ """HTTPConnection subclass that supports timeouts"""
+
+ def __init__(self, host, port=None, strict=None, timeout=None, proxy_info=None):
+ httplib.HTTPConnection.__init__(self, host, port, strict)
+ self.timeout = timeout
+ self.proxy_info = proxy_info
+
+ def connect(self):
+ """Connect to the host and port specified in __init__."""
+ # Mostly verbatim from httplib.py.
+ msg = "getaddrinfo returns an empty list"
+ for res in socket.getaddrinfo(self.host, self.port, 0,
+ socket.SOCK_STREAM):
+ af, socktype, proto, canonname, sa = res
+ try:
+ if self.proxy_info and self.proxy_info.isgood():
+ self.sock = socks.socksocket(af, socktype, proto)
+ self.sock.setproxy(*self.proxy_info.astuple())
+ else:
+ self.sock = socket.socket(af, socktype, proto)
+ # Different from httplib: support timeouts.
+ if self.timeout is not None:
+ self.sock.settimeout(self.timeout)
+ # End of difference from httplib.
+ if self.debuglevel > 0:
+ print "connect: (%s, %s)" % (self.host, self.port)
+ self.sock.connect(sa)
+ except socket.error, msg:
+ if self.debuglevel > 0:
+ print 'connect fail:', (self.host, self.port)
+ if self.sock:
+ self.sock.close()
+ self.sock = None
+ continue
+ break
+ if not self.sock:
+ raise socket.error, msg
+
+class HTTPSConnectionWithTimeout(httplib.HTTPSConnection):
+ "This class allows communication via SSL."
+
+ def __init__(self, host, port=None, key_file=None, cert_file=None,
+ strict=None, timeout=None, proxy_info=None):
+ self.timeout = timeout
+ self.proxy_info = proxy_info
+ httplib.HTTPSConnection.__init__(self, host, port=port, key_file=key_file,
+ cert_file=cert_file, strict=strict)
+
+ def connect(self):
+ "Connect to a host on a given (SSL) port."
+
+ if self.proxy_info and self.proxy_info.isgood():
+ self.sock.setproxy(*self.proxy_info.astuple())
+ sock.setproxy(*self.proxy_info.astuple())
+ else:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ if self.timeout is not None:
+ sock.settimeout(self.timeout)
+ sock.connect((self.host, self.port))
+ ssl = socket.ssl(sock, self.key_file, self.cert_file)
+ self.sock = httplib.FakeSocket(sock, ssl)
+
+
+
+class Http(object):
+ """An HTTP client that handles:
+- all methods
+- caching
+- ETags
+- compression,
+- HTTPS
+- Basic
+- Digest
+- WSSE
+
+and more.
+ """
+ def __init__(self, cache=None, timeout=None, proxy_info=None):
+ """The value of proxy_info is a ProxyInfo instance.
+
+If 'cache' is a string then it is used as a directory name
+for a disk cache. Otherwise it must be an object that supports
+the same interface as FileCache."""
+ self.proxy_info = proxy_info
+ # Map domain name to an httplib connection
+ self.connections = {}
+ # The location of the cache, for now a directory
+ # where cached responses are held.
+ if cache and isinstance(cache, str):
+ self.cache = FileCache(cache)
+ else:
+ self.cache = cache
+
+ # Name/password
+ self.credentials = Credentials()
+
+ # Key/cert
+ self.certificates = KeyCerts()
+
+ # authorization objects
+ self.authorizations = []
+
+ # If set to False then no redirects are followed, even safe ones.
+ self.follow_redirects = True
+
+ # If 'follow_redirects' is True, and this is set to True then
+ # all redirecs are followed, including unsafe ones.
+ self.follow_all_redirects = False
+
+ self.ignore_etag = False
+
+ self.force_exception_to_status_code = False
+
+ self.timeout = timeout
+
+ def _auth_from_challenge(self, host, request_uri, headers, response, content):
+ """A generator that creates Authorization objects
+ that can be applied to requests.
+ """
+ challenges = _parse_www_authenticate(response, 'www-authenticate')
+ for cred in self.credentials.iter(host):
+ for scheme in AUTH_SCHEME_ORDER:
+ if challenges.has_key(scheme):
+ yield AUTH_SCHEME_CLASSES[scheme](cred, host, request_uri, headers, response, content, self)
+
+ def add_credentials(self, name, password, domain=""):
+ """Add a name and password that will be used
+ any time a request requires authentication."""
+ self.credentials.add(name, password, domain)
+
+ def add_certificate(self, key, cert, domain):
+ """Add a key and cert that will be used
+ any time a request requires authentication."""
+ self.certificates.add(key, cert, domain)
+
+ def clear_credentials(self):
+ """Remove all the names and passwords
+ that are used for authentication"""
+ self.credentials.clear()
+ self.authorizations = []
+
+ def _conn_request(self, conn, request_uri, method, body, headers):
+ for i in range(2):
+ try:
+ conn.request(method, request_uri, body, headers)
+ response = conn.getresponse()
+ except socket.gaierror:
+ conn.close()
+ raise ServerNotFoundError("Unable to find the server at %s" % conn.host)
+ except httplib.HTTPException, e:
+ if i == 0:
+ conn.close()
+ conn.connect()
+ continue
+ else:
+ raise
+ else:
+ content = response.read()
+ response = Response(response)
+ if method != "HEAD":
+ content = _decompressContent(response, content)
+
+ break;
+ return (response, content)
+
+
+ def _request(self, conn, host, absolute_uri, request_uri, method, body, headers, redirections, cachekey):
+ """Do the actual request using the connection object
+ and also follow one level of redirects if necessary"""
+
+ auths = [(auth.depth(request_uri), auth) for auth in self.authorizations if auth.inscope(host, request_uri)]
+ auth = auths and sorted(auths)[0][1] or None
+ if auth:
+ auth.request(method, request_uri, headers, body)
+
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers)
+
+ if auth:
+ if auth.response(response, body):
+ auth.request(method, request_uri, headers, body)
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers )
+ response._stale_digest = 1
+
+ if response.status == 401:
+ for authorization in self._auth_from_challenge(host, request_uri, headers, response, content):
+ authorization.request(method, request_uri, headers, body)
+ (response, content) = self._conn_request(conn, request_uri, method, body, headers, )
+ if response.status != 401:
+ self.authorizations.append(authorization)
+ authorization.response(response, body)
+ break
+
+ if (self.follow_all_redirects or (method in ["GET", "HEAD"]) or response.status == 303):
+ if self.follow_redirects and response.status in [300, 301, 302, 303, 307]:
+ # Pick out the location header and basically start from the beginning
+ # remembering first to strip the ETag header and decrement our 'depth'
+ if redirections:
+ if not response.has_key('location') and response.status != 300:
+ raise RedirectMissingLocation( _("Redirected but the response is missing a Location: header."), response, content)
+ # Fix-up relative redirects (which violate an RFC 2616 MUST)
+ if response.has_key('location'):
+ location = response['location']
+ (scheme, authority, path, query, fragment) = parse_uri(location)
+ if authority == None:
+ response['location'] = urlparse.urljoin(absolute_uri, location)
+ if response.status == 301 and method in ["GET", "HEAD"]:
+ response['-x-permanent-redirect-url'] = response['location']
+ if not response.has_key('content-location'):
+ response['content-location'] = absolute_uri
+ _updateCache(headers, response, content, self.cache, cachekey)
+ if headers.has_key('if-none-match'):
+ del headers['if-none-match']
+ if headers.has_key('if-modified-since'):
+ del headers['if-modified-since']
+ if response.has_key('location'):
+ location = response['location']
+ old_response = copy.deepcopy(response)
+ if not old_response.has_key('content-location'):
+ old_response['content-location'] = absolute_uri
+ redirect_method = ((response.status == 303) and (method not in ["GET", "HEAD"])) and "GET" or method
+ (response, content) = self.request(location, redirect_method, body=body, headers = headers, redirections = redirections - 1)
+ response.previous = old_response
+ else:
+ raise RedirectLimit( _("Redirected more times than rediection_limit allows."), response, content)
+ elif response.status in [200, 203] and method == "GET":
+ # Don't cache 206's since we aren't going to handle byte range requests
+ if not response.has_key('content-location'):
+ response['content-location'] = absolute_uri
+ _updateCache(headers, response, content, self.cache, cachekey)
+
+ return (response, content)
+
+
+# Need to catch and rebrand some exceptions
+# Then need to optionally turn all exceptions into status codes
+# including all socket.* and httplib.* exceptions.
+
+
+ def request(self, uri, method="GET", body=None, headers=None, redirections=DEFAULT_MAX_REDIRECTS, connection_type=None):
+ """ Performs a single HTTP request.
+The 'uri' is the URI of the HTTP resource and can begin
+with either 'http' or 'https'. The value of 'uri' must be an absolute URI.
+
+The 'method' is the HTTP method to perform, such as GET, POST, DELETE, etc.
+There is no restriction on the methods allowed.
+
+The 'body' is the entity body to be sent with the request. It is a string
+object.
+
+Any extra headers that are to be sent with the request should be provided in the
+'headers' dictionary.
+
+The maximum number of redirect to follow before raising an
+exception is 'redirections. The default is 5.
+
+The return value is a tuple of (response, content), the first
+being and instance of the 'Response' class, the second being
+a string that contains the response entity body.
+ """
+ try:
+ if headers is None:
+ headers = {}
+ else:
+ headers = _normalize_headers(headers)
+
+ if not headers.has_key('user-agent'):
+ headers['user-agent'] = "Python-httplib2/%s" % __version__
+
+ uri = iri2uri(uri)
+
+ (scheme, authority, request_uri, defrag_uri) = urlnorm(uri)
+
+ conn_key = scheme+":"+authority
+ if conn_key in self.connections:
+ conn = self.connections[conn_key]
+ else:
+ if not connection_type:
+ connection_type = (scheme == 'https') and HTTPSConnectionWithTimeout or HTTPConnectionWithTimeout
+ certs = list(self.certificates.iter(authority))
+ if scheme == 'https' and certs:
+ conn = self.connections[conn_key] = connection_type(authority, key_file=certs[0][0],
+ cert_file=certs[0][1], timeout=self.timeout, proxy_info=self.proxy_info)
+ else:
+ conn = self.connections[conn_key] = connection_type(authority, timeout=self.timeout, proxy_info=self.proxy_info)
+ conn.set_debuglevel(debuglevel)
+
+ if method in ["GET", "HEAD"] and 'range' not in headers:
+ headers['accept-encoding'] = 'compress, gzip'
+
+ info = email.Message.Message()
+ cached_value = None
+ if self.cache:
+ cachekey = defrag_uri
+ cached_value = self.cache.get(cachekey)
+ if cached_value:
+ info = email.message_from_string(cached_value)
+ try:
+ content = cached_value.split('\r\n\r\n', 1)[1]
+ except IndexError:
+ self.cache.delete(cachekey)
+ cachekey = None
+ cached_value = None
+ else:
+ cachekey = None
+
+ if method in ["PUT"] and self.cache and info.has_key('etag') and not self.ignore_etag and 'if-match' not in headers:
+ # http://www.w3.org/1999/04/Editing/
+ headers['if-match'] = info['etag']
+
+ if method not in ["GET", "HEAD"] and self.cache and cachekey:
+ # RFC 2616 Section 13.10
+ self.cache.delete(cachekey)
+
+ if cached_value and method in ["GET", "HEAD"] and self.cache and 'range' not in headers:
+ if info.has_key('-x-permanent-redirect-url'):
+ # Should cached permanent redirects be counted in our redirection count? For now, yes.
+ (response, new_content) = self.request(info['-x-permanent-redirect-url'], "GET", headers = headers, redirections = redirections - 1)
+ response.previous = Response(info)
+ response.previous.fromcache = True
+ else:
+ # Determine our course of action:
+ # Is the cached entry fresh or stale?
+ # Has the client requested a non-cached response?
+ #
+ # There seems to be three possible answers:
+ # 1. [FRESH] Return the cache entry w/o doing a GET
+ # 2. [STALE] Do the GET (but add in cache validators if available)
+ # 3. [TRANSPARENT] Do a GET w/o any cache validators (Cache-Control: no-cache) on the request
+ entry_disposition = _entry_disposition(info, headers)
+
+ if entry_disposition == "FRESH":
+ if not cached_value:
+ info['status'] = '504'
+ content = ""
+ response = Response(info)
+ if cached_value:
+ response.fromcache = True
+ return (response, content)
+
+ if entry_disposition == "STALE":
+ if info.has_key('etag') and not self.ignore_etag and not 'if-none-match' in headers:
+ headers['if-none-match'] = info['etag']
+ if info.has_key('last-modified') and not 'last-modified' in headers:
+ headers['if-modified-since'] = info['last-modified']
+ elif entry_disposition == "TRANSPARENT":
+ pass
+
+ (response, new_content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
+
+ if response.status == 304 and method == "GET":
+ # Rewrite the cache entry with the new end-to-end headers
+ # Take all headers that are in response
+ # and overwrite their values in info.
+ # unless they are hop-by-hop, or are listed in the connection header.
+
+ for key in _get_end2end_headers(response):
+ info[key] = response[key]
+ merged_response = Response(info)
+ if hasattr(response, "_stale_digest"):
+ merged_response._stale_digest = response._stale_digest
+ _updateCache(headers, merged_response, content, self.cache, cachekey)
+ response = merged_response
+ response.status = 200
+ response.fromcache = True
+
+ elif response.status == 200:
+ content = new_content
+ else:
+ self.cache.delete(cachekey)
+ content = new_content
+ else:
+ (response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
+ except Exception, e:
+ if self.force_exception_to_status_code:
+ if isinstance(e, HttpLib2ErrorWithResponse):
+ response = e.response
+ content = e.content
+ response.status = 500
+ response.reason = str(e)
+ elif isinstance(e, socket.timeout):
+ content = "Request Timeout"
+ response = Response( {
+ "content-type": "text/plain",
+ "status": "408",
+ "content-length": len(content)
+ })
+ response.reason = "Request Timeout"
+ else:
+ content = str(e)
+ response = Response( {
+ "content-type": "text/plain",
+ "status": "400",
+ "content-length": len(content)
+ })
+ response.reason = "Bad Request"
+ else:
+ raise
+
+
+ return (response, content)
+
+
+
+class Response(dict):
+ """An object more like email.Message than httplib.HTTPResponse."""
+
+ """Is this response from our local cache"""
+ fromcache = False
+
+ """HTTP protocol version used by server. 10 for HTTP/1.0, 11 for HTTP/1.1. """
+ version = 11
+
+ "Status code returned by server. "
+ status = 200
+
+ """Reason phrase returned by server."""
+ reason = "Ok"
+
+ previous = None
+
+ def __init__(self, info):
+ # info is either an email.Message or
+ # an httplib.HTTPResponse object.
+ if isinstance(info, httplib.HTTPResponse):
+ for key, value in info.getheaders():
+ self[key] = value
+ self.status = info.status
+ self['status'] = str(self.status)
+ self.reason = info.reason
+ self.version = info.version
+ elif isinstance(info, email.Message.Message):
+ for key, value in info.items():
+ self[key] = value
+ self.status = int(self['status'])
+ else:
+ for key, value in info.iteritems():
+ self[key] = value
+ self.status = int(self.get('status', self.status))
+
+
+ def __getattr__(self, name):
+ if name == 'dict':
+ return self
+ else:
+ raise AttributeError, name
diff --git a/tutorius/apilib/httplib2/__init__.pyc b/tutorius/apilib/httplib2/__init__.pyc
new file mode 100644
index 0000000..e5f8ebe
--- /dev/null
+++ b/tutorius/apilib/httplib2/__init__.pyc
Binary files differ
diff --git a/tutorius/apilib/httplib2/iri2uri.py b/tutorius/apilib/httplib2/iri2uri.py
new file mode 100644
index 0000000..70667ed
--- /dev/null
+++ b/tutorius/apilib/httplib2/iri2uri.py
@@ -0,0 +1,110 @@
+"""
+iri2uri
+
+Converts an IRI to a URI.
+
+"""
+__author__ = "Joe Gregorio (joe@bitworking.org)"
+__copyright__ = "Copyright 2006, Joe Gregorio"
+__contributors__ = []
+__version__ = "1.0.0"
+__license__ = "MIT"
+__history__ = """
+"""
+
+import urlparse
+
+
+# Convert an IRI to a URI following the rules in RFC 3987
+#
+# The characters we need to enocde and escape are defined in the spec:
+#
+# iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD
+# ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF
+# / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD
+# / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD
+# / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD
+# / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD
+# / %xD0000-DFFFD / %xE1000-EFFFD
+
+escape_range = [
+ (0xA0, 0xD7FF ),
+ (0xE000, 0xF8FF ),
+ (0xF900, 0xFDCF ),
+ (0xFDF0, 0xFFEF),
+ (0x10000, 0x1FFFD ),
+ (0x20000, 0x2FFFD ),
+ (0x30000, 0x3FFFD),
+ (0x40000, 0x4FFFD ),
+ (0x50000, 0x5FFFD ),
+ (0x60000, 0x6FFFD),
+ (0x70000, 0x7FFFD ),
+ (0x80000, 0x8FFFD ),
+ (0x90000, 0x9FFFD),
+ (0xA0000, 0xAFFFD ),
+ (0xB0000, 0xBFFFD ),
+ (0xC0000, 0xCFFFD),
+ (0xD0000, 0xDFFFD ),
+ (0xE1000, 0xEFFFD),
+ (0xF0000, 0xFFFFD ),
+ (0x100000, 0x10FFFD)
+]
+
+def encode(c):
+ retval = c
+ i = ord(c)
+ for low, high in escape_range:
+ if i < low:
+ break
+ if i >= low and i <= high:
+ retval = "".join(["%%%2X" % ord(o) for o in c.encode('utf-8')])
+ break
+ return retval
+
+
+def iri2uri(uri):
+ """Convert an IRI to a URI. Note that IRIs must be
+ passed in a unicode strings. That is, do not utf-8 encode
+ the IRI before passing it into the function."""
+ if isinstance(uri ,unicode):
+ (scheme, authority, path, query, fragment) = urlparse.urlsplit(uri)
+ authority = authority.encode('idna')
+ # For each character in 'ucschar' or 'iprivate'
+ # 1. encode as utf-8
+ # 2. then %-encode each octet of that utf-8
+ uri = urlparse.urlunsplit((scheme, authority, path, query, fragment))
+ uri = "".join([encode(c) for c in uri])
+ return uri
+
+if __name__ == "__main__":
+ import unittest
+
+ class Test(unittest.TestCase):
+
+ def test_uris(self):
+ """Test that URIs are invariant under the transformation."""
+ invariant = [
+ u"ftp://ftp.is.co.za/rfc/rfc1808.txt",
+ u"http://www.ietf.org/rfc/rfc2396.txt",
+ u"ldap://[2001:db8::7]/c=GB?objectClass?one",
+ u"mailto:John.Doe@example.com",
+ u"news:comp.infosystems.www.servers.unix",
+ u"tel:+1-816-555-1212",
+ u"telnet://192.0.2.16:80/",
+ u"urn:oasis:names:specification:docbook:dtd:xml:4.1.2" ]
+ for uri in invariant:
+ self.assertEqual(uri, iri2uri(uri))
+
+ def test_iri(self):
+ """ Test that the right type of escaping is done for each part of the URI."""
+ self.assertEqual("http://xn--o3h.com/%E2%98%84", iri2uri(u"http://\N{COMET}.com/\N{COMET}"))
+ self.assertEqual("http://bitworking.org/?fred=%E2%98%84", iri2uri(u"http://bitworking.org/?fred=\N{COMET}"))
+ self.assertEqual("http://bitworking.org/#%E2%98%84", iri2uri(u"http://bitworking.org/#\N{COMET}"))
+ self.assertEqual("#%E2%98%84", iri2uri(u"#\N{COMET}"))
+ self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}"))
+ self.assertEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}")))
+ self.assertNotEqual("/fred?bar=%E2%98%9A#%E2%98%84", iri2uri(u"/fred?bar=\N{BLACK LEFT POINTING INDEX}#\N{COMET}".encode('utf-8')))
+
+ unittest.main()
+
+
diff --git a/tutorius/apilib/httplib2/iri2uri.pyc b/tutorius/apilib/httplib2/iri2uri.pyc
new file mode 100644
index 0000000..879e719
--- /dev/null
+++ b/tutorius/apilib/httplib2/iri2uri.pyc
Binary files differ
diff --git a/tutorius/apilib/mimeTypes.py b/tutorius/apilib/mimeTypes.py
new file mode 100644
index 0000000..ff8f641
--- /dev/null
+++ b/tutorius/apilib/mimeTypes.py
@@ -0,0 +1,57 @@
+"""
+ Copyright (C) 2008 Benjamin O'Steen
+
+ This file is part of python-fedoracommons.
+
+ python-fedoracommons is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ python-fedoracommons is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with python-fedoracommons. If not, see <http://www.gnu.org/licenses/>.
+"""
+
+__license__ = 'GPL http://www.gnu.org/licenses/gpl.txt'
+__author__ = "Benjamin O'Steen <bosteen@gmail.com>"
+__version__ = '0.1'
+
+class mimeTypes(object):
+ def getDictionary(self):
+ mimetype_to_extension = {}
+ extension_to_mimetype = {}
+ mimetype_to_extension['text/plain'] = 'txt'
+ mimetype_to_extension['text/xml'] = 'xml'
+ mimetype_to_extension['text/css'] = 'css'
+ mimetype_to_extension['text/javascript'] = 'js'
+ mimetype_to_extension['text/rtf'] = 'rtf'
+ mimetype_to_extension['text/calendar'] = 'ics'
+ mimetype_to_extension['application/msword'] = 'doc'
+ mimetype_to_extension['application/msexcel'] = 'xls'
+ mimetype_to_extension['application/x-msword'] = 'doc'
+ mimetype_to_extension['application/vnd.ms-excel'] = 'xls'
+ mimetype_to_extension['application/vnd.ms-powerpoint'] = 'ppt'
+ mimetype_to_extension['application/pdf'] = 'pdf'
+ mimetype_to_extension['text/comma-separated-values'] = 'csv'
+
+
+ mimetype_to_extension['image/jpeg'] = 'jpg'
+ mimetype_to_extension['image/gif'] = 'gif'
+ mimetype_to_extension['image/jpg'] = 'jpg'
+ mimetype_to_extension['image/tiff'] = 'tiff'
+ mimetype_to_extension['image/png'] = 'png'
+
+ # And hacky reverse lookups
+ for mimetype in mimetype_to_extension:
+ extension_to_mimetype[mimetype_to_extension[mimetype]] = mimetype
+
+ mimetype_extension_mapping = {}
+ mimetype_extension_mapping.update(mimetype_to_extension)
+ mimetype_extension_mapping.update(extension_to_mimetype)
+
+ return mimetype_extension_mapping
diff --git a/tutorius/apilib/mimeTypes.pyc b/tutorius/apilib/mimeTypes.pyc
new file mode 100644
index 0000000..35ef2b2
--- /dev/null
+++ b/tutorius/apilib/mimeTypes.pyc
Binary files differ
diff --git a/tutorius/apilib/restful_lib.py b/tutorius/apilib/restful_lib.py
new file mode 100644
index 0000000..e1ee0af
--- /dev/null
+++ b/tutorius/apilib/restful_lib.py
@@ -0,0 +1,129 @@
+"""
+ Copyright (C) 2008 Benjamin O'Steen
+
+ This file is part of python-fedoracommons.
+
+ python-fedoracommons is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ python-fedoracommons is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with python-fedoracommons. If not, see <http://www.gnu.org/licenses/>.
+"""
+
+__license__ = 'GPL http://www.gnu.org/licenses/gpl.txt'
+__author__ = "Benjamin O'Steen <bosteen@gmail.com>"
+__version__ = '0.1'
+
+import httplib2
+import urlparse
+import urllib
+import base64
+from base64 import encodestring
+
+from mimeTypes import *
+
+import mimetypes
+
+from cStringIO import StringIO
+
+class Connection:
+ def __init__(self, base_url, username=None, password=None):
+ self.base_url = base_url
+ self.username = username
+ m = mimeTypes()
+ self.mimetypes = m.getDictionary()
+
+ self.url = urlparse.urlparse(base_url)
+
+ (scheme, netloc, path, query, fragment) = urlparse.urlsplit(base_url)
+
+ self.scheme = scheme
+ self.host = netloc
+ self.path = path
+
+ # Create Http class with support for Digest HTTP Authentication, if necessary
+ self.h = httplib2.Http(".cache")
+ self.h.follow_all_redirects = True
+ if username and password:
+ self.h.add_credentials(username, password)
+
+ def request_get(self, resource, args = None, headers={}):
+ return self.request(resource, "get", args, headers=headers)
+
+ def request_delete(self, resource, args = None, headers={}):
+ return self.request(resource, "delete", args, headers=headers)
+
+ def request_head(self, resource, args = None, headers={}):
+ return self.request(resource, "head", args, headers=headers)
+
+ def request_post(self, resource, args = None, body = None, filename=None, headers={}):
+ return self.request(resource, "post", args , body = body, filename=filename, headers=headers)
+
+ def request_put(self, resource, args = None, body = None, filename=None, headers={}):
+ return self.request(resource, "put", args , body = body, filename=filename, headers=headers)
+
+ def get_content_type(self, filename):
+ extension = filename.split('.')[-1]
+ guessed_mimetype = self.mimetypes.get(extension, mimetypes.guess_type(filename)[0])
+ return guessed_mimetype or 'application/octet-stream'
+
+ def request(self, resource, method = "get", args = None, body = None, filename=None, headers={}):
+ params = None
+ path = resource
+ headers['User-Agent'] = 'Basic Agent'
+
+ BOUNDARY = u'00hoYUXOnLD5RQ8SKGYVgLLt64jejnMwtO7q8XE1'
+ CRLF = u'\r\n'
+
+ if filename and body:
+ #fn = open(filename ,'r')
+ #chunks = fn.read()
+ #fn.close()
+
+ # Attempt to find the Mimetype
+ content_type = self.get_content_type(filename)
+ headers['Content-Type']='multipart/form-data; boundary='+BOUNDARY
+ encode_string = StringIO()
+ encode_string.write(CRLF)
+ encode_string.write(u'--' + BOUNDARY + CRLF)
+ encode_string.write(u'Content-Disposition: form-data; name="file"; filename="%s"' % filename)
+ encode_string.write(CRLF)
+ encode_string.write(u'Content-Type: %s' % content_type + CRLF)
+ encode_string.write(CRLF)
+ encode_string.write(body)
+ encode_string.write(CRLF)
+ encode_string.write(u'--' + BOUNDARY + u'--' + CRLF)
+
+ body = encode_string.getvalue()
+ headers['Content-Length'] = str(len(body))
+ elif body:
+ if not headers.get('Content-Type', None):
+ headers['Content-Type']='text/xml'
+ headers['Content-Length'] = str(len(body))
+ else:
+ headers['Content-Type']='text/xml'
+
+ if args:
+ path += u"?" + urllib.urlencode(args)
+
+ request_path = []
+ if self.path != "/":
+ if self.path.endswith('/'):
+ request_path.append(self.path[:-1])
+ else:
+ request_path.append(self.path)
+ if path.startswith('/'):
+ request_path.append(path[1:])
+ else:
+ request_path.append(path)
+
+ resp, content = self.h.request(u"%s://%s%s" % (self.scheme, self.host, u'/'.join(request_path)), method.upper(), body=body, headers=headers )
+
+ return {u'headers':resp, u'body':content.decode('UTF-8')}
diff --git a/tutorius/apilib/restful_lib.pyc b/tutorius/apilib/restful_lib.pyc
new file mode 100644
index 0000000..5b06765
--- /dev/null
+++ b/tutorius/apilib/restful_lib.pyc
Binary files differ
diff --git a/tutorius/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 <erick.lavoie@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+#TODO: For notification of modifications on the Tutorial check for GObject and PyDispatcher for inspiration
+
+from .constraints import ConstraintException
+from .properties import TPropContainer
+
+_NAME_SEPARATOR = "/"
+
+class Tutorial(object):
+ """ This class replaces the previous Tutorial class and
+ allows manipulation of the abstract representation
+ of a tutorial as a state machine
+ """
+
+ INIT = "INIT"
+ END = "END"
+ INITIAL_TRANSITION_NAME = INIT + "/transition0"
+
+
+ def __init__(self, name, state_dict=None):
+ """
+ The constructor for the Tutorial. By default, the tutorial contains
+ only an initial state and an end state.
+ The initial state doesn't contain any action but it contains
+ a single automatic transition <Tutorial.INITIAL_TRANSITION_NAME>
+ between the initial state <Tutorial.INIT> and the end state
+ <Tutorial.END>.
+
+ The end state doesn't contain any action nor transition.
+
+ If state_dict is provided, a valid initial state and an end state
+ must be provided.
+
+ @param name The name of the tutorial
+ @param state_dict optional, a valid dictionary of states
+ @raise InvalidStateDictionary
+ """
+ self.name = name
+
+
+ # We will use an adjacency list representation through the
+ # usage of state objects because our graph representation
+ # is really sparse and mostly linear, for a brief
+ # example of graph programming in python see:
+ # http://www.python.org/doc/essays/graphs
+ if not state_dict:
+ self._state_dict = \
+ {Tutorial.INIT:State(name=Tutorial.INIT),\
+ Tutorial.END:State(name=Tutorial.END)}
+
+ self.add_transition(Tutorial.INIT, \
+ (AutomaticTransitionEvent(), Tutorial.END))
+ else:
+ 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_WIDTH<origin[0] and \
+ self.drag_pos[0]>origin[0]:
+ self.selection.append(action)
+ self.render_action(ctx, block_width, local_height, action)
+ ctx.translate(0, local_height+BLOCK_PADDING)
+
+ ctx.restore()
+ ctx.translate(BLOCK_WIDTH, 0)
+
+ # insertion cursor painting made from two opposed triangles
+ # joined by a line.
+ if state.name == self._creator.get_insertion_point():
+ ctx.save()
+ bp2 = BLOCK_PADDING/2
+ ctx.move_to(-bp2, 0)
+ ctx.line_to(-BLOCK_PADDING-bp2, -BLOCK_PADDING)
+ ctx.line_to(bp2, -BLOCK_PADDING)
+ ctx.line_to(-bp2, 0)
+
+ ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING)
+ ctx.line_to(bp2, block_max_height-BLOCK_PADDING)
+ ctx.line_to(-BLOCK_PADDING-bp2, block_max_height-BLOCK_PADDING)
+ ctx.line_to(-bp2, block_max_height-2*BLOCK_PADDING)
+
+ ctx.line_to(-bp2, BLOCK_PADDING)
+ ctx.set_source_rgb(1.0, 1.0, 0.0)
+ ctx.stroke_preserve()
+ ctx.fill()
+ ctx.restore()
+
+
+ event_list = state.get_event_filter_list()
+ if event_list:
+ local_height = (block_max_height - BLOCK_PADDING)/len(event_list) - BLOCK_PADDING
+ ctx.save()
+ for event, next_state in event_list:
+ origin = tuple(ctx.get_matrix())[-2:]
+ if self.click_pos and \
+ self.click_pos[0]-BLOCK_WIDTH<origin[0] and \
+ self.drag_pos[0]>origin[0]:
+ self.selection.append(event)
+ self.render_event(ctx, block_width, local_height, event)
+ ctx.translate(0, local_height+BLOCK_PADDING)
+
+ ctx.restore()
+ ctx.translate(BLOCK_WIDTH, 0)
+
+ # FIXME point to next state in state, as it would highlight
+ # the "happy path".
+ cur_state = event_list[0][1]
+
+ if (not new_insert_point) and self.click_pos:
+ origin = tuple(ctx.get_matrix())[-2:]
+ if self.click_pos[0]<origin[0]:
+ new_insert_point = state
+
+ if event_list:
+ try:
+ state = states[cur_state]
+ except KeyError:
+ break
+ yield True
+ else:
+ break
+
+ ctx.set_matrix(app_start)
+ self._render_app_hints(ctx, cur_app)
+
+ if self.click_pos:
+ if not new_insert_point:
+ new_insert_point = state
+
+ self._creator.set_insertion_point(new_insert_point.name)
+
+ yield False
+
+ def _render_snapshot(self, ctx, elem):
+ """
+ Render the "simplified screenshot-like" representation of elements positions.
+ """
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.5)
+ ctx.rectangle(0, 0, SNAP_WIDTH, SNAP_HEIGHT)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ if hasattr(elem, 'position'):
+ pos = elem.position
+ # FIXME this size approximation is fine, but I believe we could
+ # do better.
+ ctx.scale(SNAP_SCALE, SNAP_SCALE)
+ ctx.rectangle(pos[0], pos[1], ACTION_WIDTH, ACTION_HEIGHT)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ def _render_app_hints(self, ctx, appname):
+ """
+ Fetches the icon of the app related to current states and renders it on a
+ separator, between states.
+ """
+ ctx.set_source_rgb(0.0, 0.0, 0.0)
+ ctx.set_dash((1,1,0,0), 1)
+ ctx.move_to(0, 0)
+ ctx.line_to(0, self.alloc.height)
+ ctx.stroke()
+ ctx.set_dash(tuple(), 1)
+
+ bundle_path = os.getenv("SUGAR_BUNDLE_PATH")
+ if bundle_path:
+ icon_path = activitybundle.ActivityBundle(bundle_path).get_icon()
+ icon = rsvg.Handle(icon_path)
+ ctx.save()
+ ctx.translate(-15, 0)
+ ctx.scale(0.5, 0.5)
+ icon_surf = icon.render_cairo(ctx)
+ ctx.restore()
+
+
+ def render_action(self, ctx, width, height, action):
+ """
+ Renders the action block, along with the icon of the action tool.
+ """
+ ctx.save()
+ inner_width = width-(BLOCK_CORNERS<<1)
+ inner_height = height-(BLOCK_CORNERS<<1)
+
+ paint_border = ctx.rel_line_to
+ filling = cairo.LinearGradient(0, 0, 0, inner_height)
+ if action not in self.selection:
+ filling.add_color_stop_rgb(0.0, 0.7, 0.7, 1.0)
+ filling.add_color_stop_rgb(1.0, 0.1, 0.1, 0.8)
+ else:
+ filling.add_color_stop_rgb(0.0, 0.4, 0.4, 0.8)
+ filling.add_color_stop_rgb(1.0, 0.0, 0.0, 0.5)
+ tracing = cairo.LinearGradient(0, 0, 0, inner_height)
+ tracing.add_color_stop_rgb(0.0, 1.0, 1.0, 1.0)
+ tracing.add_color_stop_rgb(1.0, 0.2, 0.2, 0.2)
+
+ ctx.move_to(BLOCK_CORNERS, 0)
+ paint_border(inner_width, 0)
+ ctx.arc(inner_width+BLOCK_CORNERS, BLOCK_CORNERS, BLOCK_CORNERS, -PI2, 0.0)
+ ctx.arc(inner_width+BLOCK_CORNERS, inner_height+BLOCK_CORNERS, BLOCK_CORNERS, 0.0, PI2)
+ ctx.arc(BLOCK_CORNERS, inner_height+BLOCK_CORNERS, BLOCK_CORNERS, PI2, PI)
+ ctx.arc(BLOCK_CORNERS, BLOCK_CORNERS, BLOCK_CORNERS, -PI, -PI2)
+
+ ctx.set_source(tracing)
+ ctx.stroke_preserve()
+ ctx.set_source(filling)
+ ctx.fill()
+
+ addon_name = addon.get_name_from_type(type(action))
+ # TODO use icon pool
+ icon_name = addon.get_addon_meta(addon_name)['icon']
+ rsvg_icon = rsvg.Handle(icon.get_icon_file_name(icon_name))
+ ctx.save()
+ ctx.translate(BLOCK_INNER_PAD, BLOCK_INNER_PAD)
+ ctx.scale(0.5, 0.5)
+ icon_surf = rsvg_icon.render_cairo(ctx)
+
+ ctx.restore()
+
+ ctx.translate(BLOCK_INNER_PAD, (height-SNAP_HEIGHT)/2)
+ self._render_snapshot(ctx, action)
+
+ ctx.restore()
+
+ def render_event(self, ctx, width, height, event):
+ """
+ Renders the action block, along with the icon of the action tool.
+ """
+ ctx.save()
+ inner_width = width-(BLOCK_CORNERS<<1)
+ inner_height = height-(BLOCK_CORNERS<<1)
+
+ filling = cairo.LinearGradient(0, 0, 0, inner_height)
+ if event not in self.selection:
+ filling.add_color_stop_rgb(0.0, 1.0, 0.8, 0.6)
+ filling.add_color_stop_rgb(1.0, 1.0, 0.6, 0.2)
+ else:
+ filling.add_color_stop_rgb(0.0, 0.8, 0.6, 0.4)
+ filling.add_color_stop_rgb(1.0, 0.6, 0.4, 0.1)
+ tracing = cairo.LinearGradient(0, 0, 0, inner_height)
+ tracing.add_color_stop_rgb(0.0, 1.0, 1.0, 1.0)
+ tracing.add_color_stop_rgb(1.0, 0.3, 0.3, 0.3)
+
+ ctx.move_to(BLOCK_CORNERS, 0)
+ ctx.rel_line_to(inner_width, 0)
+ ctx.rel_line_to(BLOCK_CORNERS, BLOCK_CORNERS)
+ ctx.rel_line_to(0, inner_height)
+ ctx.rel_line_to(-BLOCK_CORNERS, BLOCK_CORNERS)
+ ctx.rel_line_to(-inner_width, 0)
+ ctx.rel_line_to(-BLOCK_CORNERS, -BLOCK_CORNERS)
+ ctx.rel_line_to(0, -inner_height)
+ ctx.close_path()
+
+ ctx.set_source(tracing)
+ ctx.stroke_preserve()
+ ctx.set_source(filling)
+ ctx.fill()
+
+ addon_name = addon.get_name_from_type(type(event))
+ # TODO use icon pool
+ icon_name = addon.get_addon_meta(addon_name)['icon']
+ rsvg_icon = rsvg.Handle(icon.get_icon_file_name(icon_name))
+ ctx.save()
+ ctx.translate(BLOCK_INNER_PAD, BLOCK_INNER_PAD)
+ ctx.scale(0.5, 0.5)
+ icon_surf = rsvg_icon.render_cairo(ctx)
+
+ ctx.restore()
+
+ ctx.translate(BLOCK_INNER_PAD, (height-SNAP_HEIGHT)/2)
+ self._render_snapshot(ctx, event)
+
+ ctx.restore()
+
+ def on_viewer_expose(self, widget, evt, states):
+ """
+ Expose signal handler for the viewer's DrawingArea.
+ This loops through states and renders every action and transition of
+ the "happy path".
+
+ @param widget: the gtk.DrawingArea on which to draw
+ @param evt: the gtk.gdk.Event containing an "expose" event
+ @param states: a tutorius FiniteStateMachine object to paint
+ """
+ ctx = widget.window.cairo_create()
+ self.alloc = widget.get_allocation()
+ ctx.set_source_pixmap(widget.window,
+ widget.allocation.x,
+ widget.allocation.y)
+
+ # draw no more than our expose event intersects our child
+ region = gtk.gdk.region_rectangle(widget.allocation)
+ r = gtk.gdk.region_rectangle(evt.area)
+ region.intersect(r)
+ ctx.region (region)
+ ctx.clip()
+ ctx.paint()
+
+ ctx.translate(BLOCK_PADDING, BLOCK_PADDING)
+
+ painter = self._paint_state(ctx, states)
+ while painter.next(): pass
+
+ if self.click_pos and self.drag_pos:
+ ctx.set_matrix(cairo.Matrix())
+ ctx.rectangle(self.click_pos[0], self.click_pos[1],
+ self.drag_pos[0]-self.click_pos[0],
+ self.drag_pos[1]-self.click_pos[1])
+ ctx.set_source_rgba(0, 0, 1, 0.5)
+ ctx.fill_preserve()
+ ctx.stroke()
+
+ return False
+
+ def _on_click(self, widget, evt):
+ # the rendering pipeline will work out the click validation process
+ self.drag_pos = None
+ self.drag_pos = self.click_pos = evt.get_coords()
+ widget.queue_draw()
+
+ self.selection = []
+
+ def _on_drag(self, widget, evt):
+ self.drag_pos = evt.get_coords()
+ widget.queue_draw()
+
+ def _on_drag_end(self, widget, evt):
+ self.click_pos = self.drag_pos = None
+ widget.queue_draw()
+
+ def _on_key_press(self, widget, evt):
+ if evt.keyval == gtk.keysyms.BackSpace:
+ # remove selection
+ for selected in self.selection:
+ if isinstance(selected, EventFilter):
+ self._creator.delete_state()
+ else:
+ self._creator.delete_action(selected)
+ widget.queue_draw()
+
+