← Back to team overview

openerp-dev-web team mailing list archive

lp:~openerp-dev/openobject-client-web/proto61-tests-and-runners-xmo into lp:~openerp-dev/openobject-client-web/trunk-proto61

 

Xavier (Open ERP) has proposed merging lp:~openerp-dev/openobject-client-web/proto61-tests-and-runners-xmo into lp:~openerp-dev/openobject-client-web/trunk-proto61.

Requested reviews:
  OpenERP R&D Team (openerp-dev)

For more details, see:
https://code.launchpad.net/~openerp-dev/openobject-client-web/proto61-tests-and-runners-xmo/+merge/54202

* Addition of an initial test (menu loading) to the base addon
  + documentation of tests procedures (libraries, launching tests, location) in addons
  + reformatting of menu loading
* Addition of an initial test (OpenERPModel behavior) in core
  + documentation of testing in core
* Added test dependencies in setup.py, set test_suite in setup.py so it's possible to run tests in core via `setup.py test`
* Added some documentation on addon structure
-- 
https://code.launchpad.net/~openerp-dev/openobject-client-web/proto61-tests-and-runners-xmo/+merge/54202
Your team OpenERP R&D Team is requested to review the proposed merge of lp:~openerp-dev/openobject-client-web/proto61-tests-and-runners-xmo into lp:~openerp-dev/openobject-client-web/trunk-proto61.
=== modified file 'addons/base/controllers/main.py'
--- addons/base/controllers/main.py	2011-03-17 17:14:03 +0000
+++ addons/base/controllers/main.py	2011-03-21 12:36:36 +0000
@@ -25,14 +25,14 @@
     @staticmethod
     def convert_element(el, skip_whitespaces=True):
         res = {}
-        if el.tag[0]=="{":
-            ns, name = el.tag.rsplit("}",1) 
+        if el.tag[0] == "{":
+            ns, name = el.tag.rsplit("}", 1)
             res["tag"] = name
             res["namespace"] = ns[1:]
         else:
             res["tag"] = el.tag
         res["attrs"] = {}
-        for k,v in el.items():
+        for k, v in el.items():
             res["attrs"][k] = v
         kids = []
         if el.text and (not skip_whitespaces or el.text.strip() != ''):
@@ -48,20 +48,6 @@
 # OpenERP Web base Controllers
 #----------------------------------------------------------
 
-class Hello(openerpweb.Controller):
-    _cp_path = "/base/hello"
-
-    def index(self):
-        return "hello world"
-
-    @openerpweb.jsonrequest
-    def ajax_hello_world(self,req):
-        return {"welcome":"hello world"}
-
-    @openerpweb.jsonrequest
-    def ajax_hello_error(self,req):
-        raise Exception("You suck")
-
 class Session(openerpweb.Controller):
     _cp_path = "/base/session"
 
@@ -84,7 +70,7 @@
         files_content = []
         files_timestamp = 0
         for i in file_list:
-            fname = os.path.join(root,i)
+            fname = os.path.join(root, i)
             ftime = os.path.getmtime(fname)
             if ftime > files_timestamp:
                 files_timestamp = ftime
@@ -96,7 +82,7 @@
     def login(self, req, db, login, password):
         req.session.login(db, login, password)
         return {
-            "session_id" : req.session_id,
+            "session_id": req.session_id,
             "uid": req.session._uid,
         }
 
@@ -117,69 +103,83 @@
         concat = self.concat_files(files)[0]
         # TODO request set the Date of last modif and Etag
         return concat
-    css.exposed=1
+    css.exposed = True
 
     def js(self, req, mods='base,base_hello'):
         files = self.manifest_glob(mods.split(','), 'js')
         concat = self.concat_files(files)[0]
         # TODO request set the Date of last modif and Etag
         return concat
-    js.exposed=1
+    js.exposed = True
+
 
 class Menu(openerpweb.Controller):
     _cp_path = "/base/menu"
 
     @openerpweb.jsonrequest
-    def load(self,req):
-        m = req.session.model('ir.ui.menu')
+    def load(self, req):
+        return {'data': self.do_load(req)}
+
+    def do_load(self, req):
+        """ Loads all menu items (all applications and their sub-menus).
+
+        :param req: A request object, with an OpenERP session attribute
+        :type req: < session -> OpenERPSession >
+        :return: the menu root
+        :rtype: dict('children': menu_nodes)
+        """
+        Menus = req.session.model('ir.ui.menu')
         # menus are loaded fully unlike a regular tree view, cause there are
         # less than 512 items
-        menu_ids = m.search([])
-        menu_items = m.read(menu_ids,['name','sequence','parent_id'])
-        menu_root = {'id':False, 'name':'root', 'parent_id':[-1,'']}
+        menu_ids = Menus.search([])
+        menu_items = Menus.read(menu_ids, ['name', 'sequence', 'parent_id'])
+        menu_root = {'id': False, 'name': 'root', 'parent_id': [-1, '']}
         menu_items.append(menu_root)
+        
         # make a tree using parent_id
-        for i in menu_items:
-            i['children'] = []
-        d = dict([(i["id"],i) for i in menu_items])
-        for i in menu_items:
-            if not i['parent_id']:
-                pid = False
-            else:
-                pid = i['parent_id'][0]
-            if pid in d:
-                d[pid]['children'].append(i)
+        menu_items_map = dict((menu_item["id"], menu_item) for menu_item in menu_items)
+        for menu_item in menu_items:
+            if not menu_item['parent_id']: continue
+            parent = menu_item['parent_id'][0]
+            if parent in menu_items_map:
+                menu_items_map[parent].setdefault(
+                    'children', []).append(menu_item)
+
         # sort by sequence a tree using parent_id
-        for i in menu_items:
-            i['children'].sort(key = lambda x:x["sequence"])
+        for menu_item in menu_items:
+            menu_item.setdefault('children', []).sort(
+                key=lambda x:x["sequence"])
 
-        return {'data': menu_root}
+        return menu_root
 
     @openerpweb.jsonrequest
-    def action(self,req,menu_id):
+    def action(self, req, menu_id):
         m = req.session.model('ir.values')
         r = m.get('action', 'tree_but_open', [('ir.ui.menu', menu_id)], False, {})
-        res={"action":r}
+        res = {"action": r}
         return res
 
+
 class DataSet(openerpweb.Controller):
     _cp_path = "/base/dataset"
 
     @openerpweb.jsonrequest
-    def fields(self,req,model):
+    def fields(self, req, model):
         return {'fields': req.session.model(model).fields_get(False)}
 
     @openerpweb.jsonrequest
-    def load(self,req,model,domain=[],fields=['id']):
+    def load(self, req, model, domain=[], fields=['id']):
         m = req.session.model(model)
         ids = m.search(domain)
         values = m.read(ids, fields)
         return {'ids': ids, 'values': values}
 
+
 class DataRecord(openerpweb.Controller):
     _cp_path = "/base/datarecord"
+
     @openerpweb.jsonrequest
-    def load(self,req,model,id,fields):
+    def load(self, req, model, id, fields):
         m = req.session.model(model)
         value = {}
         r = m.read([id])
@@ -187,36 +187,43 @@
             value = r[0]
         return {'value': value}
 
+
 class FormView(openerpweb.Controller):
     _cp_path = "/base/formview"
+
     @openerpweb.jsonrequest
-    def load(self,req,model,view_id):
+    def load(self, req, model, view_id):
         m = req.session.model(model)
-        r = m.fields_view_get(view_id,'form')
-        r["arch"]=Xml2Json.convert_to_structure(r["arch"])
-        return {'fields_view':r}
+        r = m.fields_view_get(view_id, 'form')
+        r["arch"] = Xml2Json.convert_to_structure(r["arch"])
+        return {'fields_view': r}
+
 
 class ListView(openerpweb.Controller):
     _cp_path = "/base/listview"
+
     @openerpweb.jsonrequest
-    def load(self,req,model,view_id):
+    def load(self, req, model, view_id):
         m = req.session.model(model)
-        r = m.fields_view_get(view_id,'tree')
-        r["arch"]=Xml2Json.convert_to_structure(r["arch"])
-        return {'fields_view':r}
+        r = m.fields_view_get(view_id, 'tree')
+        r["arch"] = Xml2Json.convert_to_structure(r["arch"])
+        return {'fields_view': r}
+
 
 class SearchView(openerpweb.Controller):
     _cp_path = "/base/searchview"
+
     @openerpweb.jsonrequest
-    def load(self,req,model,view_id):
+    def load(self, req, model, view_id):
         m = req.session.model(model)
-        r = m.fields_view_get(view_id,'search')
-        r["arch"]=Xml2Json.convert_to_structure(r["arch"])
-        return {'fields_view':r}
+        r = m.fields_view_get(view_id, 'search')
+        r["arch"] = Xml2Json.convert_to_structure(r["arch"])
+        return {'fields_view': r}
+
 
 class Action(openerpweb.Controller):
     _cp_path = "/base/action"
 
     @openerpweb.jsonrequest
-    def load(self,req,action_id):
+    def load(self, req, action_id):
         return {}

=== modified file 'addons/base/static/openerp/js/base_chrome.js'
--- addons/base/static/openerp/js/base_chrome.js	2011-03-20 20:01:17 +0000
+++ addons/base/static/openerp/js/base_chrome.js	2011-03-21 12:36:36 +0000
@@ -114,7 +114,7 @@
 openerp.base.Console =  openerp.base.BasicController.extend({
     init: function(element_id, server, port) {
         this._super(element_id);
-    },
+    }
 });
 
 openerp.base.Session = openerp.base.BasicController.extend({

=== modified file 'addons/base/static/openerp/js/base_views.js'
--- addons/base/static/openerp/js/base_views.js	2011-03-18 00:38:17 +0000
+++ addons/base/static/openerp/js/base_views.js	2011-03-21 12:36:36 +0000
@@ -273,9 +273,9 @@
     },
     on_record_loaded: function() {
         for (var f in this.fields) {
-            this.fields[f].set_value()
+            this.fields[f].set_value();
         }
-    },
+    }
 });
 
 openerp.base.ListView = openerp.base.Controller.extend({
@@ -536,7 +536,7 @@
     },
     on_change: function() {
         //this.view.update_field(this.name,value);
-    },
+    }
 });
 
 openerp.base.FieldEmail = openerp.base.Field.extend({
@@ -637,7 +637,7 @@
     'boolean' : openerp.base.FieldBoolean,
     'float' : openerp.base.FieldFloat,
     'button' : openerp.base.WidgetButton
-}
+};
 
 openerp.base.CalendarView = openerp.base.Controller.extend({
 // Dhtmlx scheduler ?

=== added directory 'addons/base/tests'
=== added file 'addons/base/tests/__init__.py'
--- addons/base/tests/__init__.py	1970-01-01 00:00:00 +0000
+++ addons/base/tests/__init__.py	2011-03-21 12:36:36 +0000
@@ -0,0 +1,1 @@
+# -*- coding: utf-8 -*-

=== added file 'addons/base/tests/test_menu.py'
--- addons/base/tests/test_menu.py	1970-01-01 00:00:00 +0000
+++ addons/base/tests/test_menu.py	2011-03-21 12:36:36 +0000
@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+import mock
+import unittest2
+import base.controllers.main
+import openerpweb.openerpweb
+
+class Placeholder(object):
+    def __init__(self, **kwargs):
+        for k, v in kwargs.iteritems():
+            setattr(self, k, v)
+
+class LoadTest(unittest2.TestCase):
+    def setUp(self):
+        self.menu = base.controllers.main.Menu()
+        self.menus_mock = mock.Mock()
+        self.request = Placeholder(
+            session=openerpweb.openerpweb.OpenERPSession(
+                model_factory=lambda _session, _name: self.menus_mock))
+
+    def tearDown(self):
+        del self.request
+        del self.menus_mock
+        del self.menu
+
+    def test_empty(self):
+        self.menus_mock.search = mock.Mock(return_value=[])
+        self.menus_mock.read = mock.Mock(return_value=[])
+
+        root = self.menu.do_load(self.request)
+
+        self.menus_mock.search.assert_called_with([])
+        self.menus_mock.read.assert_called_with(
+            [], ['name', 'sequence', 'parent_id'])
+
+        self.assertListEqual(
+            root['children'],
+            [])
+
+    def test_applications_sort(self):
+        self.menus_mock.search = mock.Mock(return_value=[1, 2, 3])
+        self.menus_mock.read = mock.Mock(return_value=[
+            {'id': 2, 'sequence': 3, 'parent_id': [False, '']},
+            {'id': 3, 'sequence': 2, 'parent_id': [False, '']},
+            {'id': 1, 'sequence': 1, 'parent_id': [False, '']},
+        ])
+
+        root = self.menu.do_load(self.request)
+
+        self.menus_mock.read.assert_called_with(
+            [1, 2, 3], ['name', 'sequence', 'parent_id'])
+
+        self.assertEqual(
+            root['children'],
+            [{
+                'id': 1, 'sequence': 1,
+                'parent_id': [False, ''], 'children': []
+            }, {
+                'id': 3, 'sequence': 2,
+                'parent_id': [False, ''], 'children': []
+            }, {
+                'id': 2, 'sequence': 3,
+                'parent_id': [False, ''], 'children': []
+            }])
+
+    def test_deep(self):
+        self.menus_mock.search = mock.Mock(return_value=[1, 2, 3, 4])
+        self.menus_mock.read = mock.Mock(return_value=[
+            {'id': 1, 'sequence': 1, 'parent_id': [False, '']},
+            {'id': 2, 'sequence': 2, 'parent_id': [1, '']},
+            {'id': 3, 'sequence': 1, 'parent_id': [2, '']},
+            {'id': 4, 'sequence': 2, 'parent_id': [2, '']},
+        ])
+
+        root = self.menu.do_load(self.request)
+
+        self.assertEqual(
+            root['children'],
+            [{
+                 'id': 1,
+                 'sequence': 1,
+                 'parent_id': [False, ''],
+                 'children': [{
+                     'id': 2,
+                     'sequence': 2,
+                     'parent_id': [1, ''],
+                     'children': [{
+                         'id': 3,
+                         'sequence': 1,
+                         'parent_id': [2, ''],
+                         'children': []
+                     }, {
+                         'id': 4,
+                         'sequence': 2,
+                         'parent_id': [2, ''],
+                         'children': []
+                     }]
+                 }]
+            }]
+        )

=== modified file 'addons/base_hello/static/openerp/base_hello.js'
--- addons/base_hello/static/openerp/base_hello.js	2011-03-17 12:22:39 +0000
+++ addons/base_hello/static/openerp/base_hello.js	2011-03-21 12:36:36 +0000
@@ -7,12 +7,12 @@
 openerp.base.SearchView = openerp.base.SearchView.extend({
     init:function() {
         this._super.apply(this,arguments);
-        this.on_search.add(function(){alert('hello')});
-    },
+        this.on_search.add(function(){console.log('hello');});
+    }
 });
 
 // here you may tweak globals object, if any, and play with on_* or do_* callbacks on them
 
-}
+};
 
 // vim:et fdc=0 fdl=0:

=== added file 'doc/source/addon-structure.txt'
--- doc/source/addon-structure.txt	1970-01-01 00:00:00 +0000
+++ doc/source/addon-structure.txt	2011-03-21 12:36:36 +0000
@@ -0,0 +1,9 @@
+<addon name>
+  +-- __openerp__.py
+  +-- controllers/
+  +-- static/
+       +-- openerp/
+             +-- css/
+             +-- img/
+             +-- js/
+  +-- tests/

=== modified file 'doc/source/addons.rst'
--- doc/source/addons.rst	2011-03-18 15:52:47 +0000
+++ doc/source/addons.rst	2011-03-21 12:36:36 +0000
@@ -1,7 +1,91 @@
 Developing OpenERP Web Addons
 =============================
 
-* Structure of an addon
+Structure
+---------
+
+.. literalinclude:: addon-structure.txt
+
+``__openerp__.py``
+  The addon's descriptor, contains the following information:
+
+  ``name: str``
+    The addon name, in plain, readable english
+  ``version: str``
+    The addon version, following `Semantic Versioning`_ rules
+  ``depends: [str]``
+    A list of addons this addon needs to work correctly. ``base`` is
+    an implied dependency if the list is empty.
+  ``css: [str]``
+    An ordered list of CSS files this addon provides and needs. The
+    file paths are relative to the addon's root. Because the Web
+    Client *may* perform concatenations and other various
+    optimizations on CSS files, the order is important.
+  ``js: [str]``
+    An ordered list of Javascript files this addon provides and needs
+    (including dependencies files). As with CSS files, the order is
+    important as the Web Client *may* perform contatenations and
+    minimizations of files.
+  ``active: bool``
+    Whether this addon should be enabled by default any time it is
+    found, or whether it will be enabled through other means (on a
+    by-need or by-installation basis for instance).
+
+``controllers/``
+  All of the Python controllers and JSON-RPC endpoints.
+
+``static/``
+  The static files directory, may be served via a separate web server.
+
+  The third-party dependencies should be bundled in it (each in their
+  own directory).
+
+``static/openerp/``
+  Sub-tree for all the addon's own static files.
+
+``static/openerp/{css,js,img}``
+  Location for (respectively) the addon's static CSS files, its JS
+  files and its various image resources.
+
+``tests/``
+  The directories in which all tests for the addon are located.
+
+.. _addons-testing:
+
+Testing
+-------
+
+Python
+++++++
+
+OpenERP Web uses unittest2_ for its testing needs. We selected
+unittest2 rather than unittest_ for the following reasons:
+
+* autodiscovery_ (similar to nose, via the ``unit2``
+  CLI utility) and `pluggable test discovery`_.
+
+* `new and improved assertions`_ (with improvements in type-specific
+  inequality reportings) including `pluggable custom types equality
+  assertions`_
+
+* neveral new APIs, most notably `assertRaises context manager`_,
+  `cleanup function registration`_, `test skipping`_ and `class- and
+  module-level setup and teardown`_
+
+* finally, unittest2 is a backport of Python 3's unittest. We might as
+  well get used to it.
+
+To run tests on addons (from the root directory of OpenERP Web) is as
+simple as typing ``PYTHONPATH=. unit2 discover -s addons`` [#]_. To
+test an addon which does not live in the ``addons`` directory, simply
+replace ``addons`` by the directory in which your own addon lives.
+
+.. note:: unittest2 is entirely compatible with nose_ (or the
+     other way around). If you want to use nose as your test
+     runner (due to its addons for instance) you can simply install it
+     and run ``nosetests addons`` instead of the ``unit2`` command,
+     the result should be exactly the same.
+
 * Addons lifecycle (loading, execution, events, ...)
 
   * Python-side
@@ -16,3 +100,54 @@
 * Javascript public APIs
 * QWeb templates description?
 * OpenERP Web modules (from OpenERP modules)
+
+.. [#] the ``-s`` parameter tells ``unit2`` to start trying to
+       find tests in the provided directory (here we're testing
+       addons). However a side-effect of that is to set the
+       ``PYTHONPATH`` there as well, so it will fail to find (and
+       import) ``openerpweb``.
+
+       The ``-t`` parameter lets us set the ``PYTHONPATH``
+       independently, but it doesn't accept multiple values and here
+       we really want to have both ``.`` and ``addons`` on the
+       ``PYTHONPATH``.
+
+       The solution is to set the ``PYTHONPATH`` to ``.`` on start,
+       and the ``start-directory`` to ``addons``. This results in a
+       correct ``PYTHONPATH`` within ``unit2``.
+
+.. _unittest:
+    http://docs.python.org/library/unittest.html
+
+.. _unittest2:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml
+
+.. _autodiscovery:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml#test-discovery
+
+.. _pluggable test discovery:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml#load-tests
+
+.. _new and improved assertions:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml#new-assert-methods
+
+.. _pluggable custom types equality assertions:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml#add-new-type-specific-functions
+
+.. _assertRaises context manager:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml#assertraises
+
+.. _cleanup function registration:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml#cleanup-functions-with-addcleanup
+
+.. _test skipping:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml#test-skipping
+
+.. _class- and module-level setup and teardown:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml#class-and-module-level-fixtures
+
+.. _Semantic Versioning:
+    http://semver.org/
+
+.. _nose:
+    http://somethingaboutorange.com/mrl/projects/nose/1.0.0/

=== modified file 'doc/source/development.rst'
--- doc/source/development.rst	2011-03-18 16:00:26 +0000
+++ doc/source/development.rst	2011-03-21 12:36:36 +0000
@@ -6,4 +6,25 @@
 * QWeb code documentation/description
 * Documentation of the OpenERP APIs and choices taken based on that?
 * Style guide and coding conventions (PEP8? More)
-* Test frameworks for Python and JS?
+* Test frameworks in JS?
+
+Testing
+-------
+
+Python
+++++++
+
+Testing for the OpenERP Web core is similar to :ref:`testing addons
+<addons-testing>`: the tests live in ``openerpweb.tests``, unittest2_
+is the testing framework and tests can be run via either unittest2
+(``unit2 discover``) or via nose_ (``nosetests``).
+
+Tests for the OpenERP Web core can also be run using ``setup.py
+test``.
+
+
+.. _unittest2:
+    http://www.voidspace.org.uk/python/articles/unittest2.shtml
+
+.. _nose:
+    http://somethingaboutorange.com/mrl/projects/nose/1.0.0/

=== modified file 'doc/source/index.rst'
--- doc/source/index.rst	2011-03-18 16:00:26 +0000
+++ doc/source/index.rst	2011-03-21 12:36:36 +0000
@@ -17,6 +17,7 @@
    addons
    development
    project
+   old-version
 
 Indices and tables
 ==================

=== added file 'doc/source/old-version.rst'
--- doc/source/old-version.rst	1970-01-01 00:00:00 +0000
+++ doc/source/old-version.rst	2011-03-21 12:36:36 +0000
@@ -0,0 +1,11 @@
+Main differences with the 6.0 client
+====================================
+
+.. No more populate.sh, use virtualenvs
+
+.. Logic is mainly in Javascript (had to make a choice between JS and
+.. Python logic)
+
+.. Templating language changes
+
+.. How to port addons and modules?

=== modified file 'doc/source/production.rst'
--- doc/source/production.rst	2011-03-18 16:00:26 +0000
+++ doc/source/production.rst	2011-03-21 12:36:36 +0000
@@ -3,6 +3,12 @@
 
 .. After release one, add upgrade instructions if any
 
+.. How about running the web client on alternative Python
+.. implementations e.g. pypy or Jython? Since the only lib with C
+.. accelerators we're using right now is SimpleJSON and it has a pure
+.. Python base component, we should be able to test and deploy on
+.. non-cpython no?
+
 In-depth configuration
 ----------------------
 

=== modified file 'openerpweb/openerpweb.py'
--- openerpweb/openerpweb.py	2011-03-20 13:21:46 +0000
+++ openerpweb/openerpweb.py	2011-03-21 12:36:36 +0000
@@ -1,7 +1,12 @@
 #!/usr/bin/python
 import functools
-
-import optparse, os, re, sys, tempfile, traceback, uuid, xmlrpclib
+import optparse
+import os
+import sys
+import tempfile
+import traceback
+import uuid
+import xmlrpclib
 
 import cherrypy
 import cherrypy.lib.static
@@ -15,30 +20,37 @@
 class OpenERPUnboundException(Exception):
     pass
 
+
 class OpenERPConnector(object):
     pass
 
+
 class OpenERPAuth(object):
     pass
 
+
 class OpenERPModel(object):
-    def __init__(self,session,model):
+    def __init__(self, session, model):
         self._session = session
         self._model = model
-    def __getattr__(self,name):
-        return lambda *l:self._session.execute(self._model,name,*l)
+
+    def __getattr__(self, name):
+        return lambda *l:self._session.execute(self._model, name, *l)
+
 
 class OpenERPSession(object):
-    def __init__(self, server='127.0.0.1', port=8069):
+    def __init__(self, server='127.0.0.1', port=8069,
+                 model_factory=OpenERPModel):
         self._server = server
         self._port = port
         self._db = False
         self._uid = False
         self._login = False
         self._password = False
+        self.model_factory = model_factory
 
     def proxy(self, service):
-        s = xmlrpctimeout.TimeoutServerProxy('http://%s:%s/xmlrpc/%s'%(self._server, self._port, service), timeout=5)
+        s = xmlrpctimeout.TimeoutServerProxy('http://%s:%s/xmlrpc/%s' % (self._server, self._port, service), timeout=5)
         return s
 
     def bind(self, db, uid, password):
@@ -52,14 +64,14 @@
         self._login = login
         return uid
 
-    def execute(self,model,func,*l,**d):
+    def execute(self, model, func, *l, **d):
         if not (self._db and self._uid and self._password):
             raise OpenERPUnboundException()
         r = self.proxy('object').execute(self._db, self._uid, self._password, model, func, *l, **d)
         return r
 
-    def model(self,model):
-        return OpenERPModel(self,model)
+    def model(self, model):
+        return self.model_factory(self, model)
 
 #----------------------------------------------------------
 # OpenERP Web RequestHandler
@@ -82,7 +94,7 @@
     """
 
     def parse(self, request):
-        self.params = request.get("params",{})
+        self.params = request.get("params", {})
         self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
         self.session = cherrypy.session.setdefault(self.session_id, OpenERPSession())
         self.context = self.params.pop('context', None)
@@ -108,7 +120,7 @@
         else:
             request = simplejson.loads(request)
         try:
-            print "--> %s.%s %s"%(controller.__class__.__name__,method.__name__,request)
+            print "--> %s.%s %s" % (controller.__class__.__name__, method.__name__, request)
             error = None
             result = method(controller, self, **self.parse(request))
         except OpenERPUnboundException:
@@ -127,7 +139,8 @@
                 'data': {
                     'type': 'server_exception',
                     'fault_code': e.faultCode,
-                    'debug': "Client %s\nServer %s" % ("".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
+                    'debug': "Client %s\nServer %s" % (
+                    "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
                 }
             }
         except Exception:
@@ -139,13 +152,13 @@
                     'debug': "Client %s" % traceback.format_exc()
                 }
             }
-        response = {"jsonrpc": "2.0",  "id": request.get('id')}
+        response = {"jsonrpc": "2.0", "id": request.get('id')}
         if error:
             response["error"] = error
         else:
             response["result"] = result
 
-        print "<--",  response
+        print "<--", response
         print
 
         content = simplejson.dumps(response)
@@ -153,31 +166,36 @@
         cherrypy.response.headers['Content-Length'] = len(content)
         return content
 
+
 def jsonrequest(f):
     @cherrypy.expose
     @functools.wraps(f)
     def json_handler(self):
         return JsonRequest().dispatch(self, f, requestf=cherrypy.request.body)
+
     return json_handler
 
+
 class HttpRequest(object):
     """ Regular GET/POST request
     """
+
     def __init__(self):
         # result may be filled, it's content will be updated by the return
         # value of the dispatched function if it's a dict
         self.result = ""
 
     def dispatch(self, controller, f, request):
-        print "GET/POST --> %s.%s %s"%(controller.__class__.__name__,f.__name__,request)
-        r=f(controller, self, request)
+        print "GET/POST --> %s.%s %s" % (controller.__class__.__name__, f.__name__, request)
+        r = f(controller, self, request)
         return r
 
+
 def httprequest(f):
     # check cleaner wrapping:
     # functools.wraps(f)(lambda x: JsonRequest().dispatch(x, f))
-    l=lambda self, request: HttpRequest().dispatch(self, f, request)
-    l.exposed=1
+    l = lambda self, request: HttpRequest().dispatch(self, f, request)
+    l.exposed = 1
     return l
 
 #-----------------------------------------------------------
@@ -185,7 +203,7 @@
 #-----------------------------------------------------------
 
 path_root = os.path.dirname(os.path.dirname(os.path.normpath(__file__)))
-path_addons = os.path.join(path_root,'addons')
+path_addons = os.path.join(path_root, 'addons')
 cherrypy_root = None
 
 # globals might move into a pool if needed
@@ -198,64 +216,68 @@
 class ControllerType(type):
     def __init__(cls, name, bases, attrs):
         super(ControllerType, cls).__init__(name, bases, attrs)
-        # TODO forgive me this hack and find me a clean way to get the absolute name of a class
-        cls.fullname = re.search("'(.+)'",repr(cls)).group(1)
-        controllers_class[cls.fullname] = cls
+        controllers_class["%s.%s" % (cls.__module__, cls.__name__)] = cls
+
 
 class Controller(object):
     __metaclass__ = ControllerType
 
+
 class Root(object):
     def __init__(self):
         self.addons = {}
         self._load_addons()
+
     def _load_addons(self):
         if path_addons not in sys.path:
-            sys.path.insert(0,path_addons)
+            sys.path.insert(0, path_addons)
         for i in os.listdir(path_addons):
             if i not in sys.modules:
-                manifest_path = os.path.join(path_addons,i,'__openerp__.py')
+                manifest_path = os.path.join(path_addons, i, '__openerp__.py')
                 if os.path.isfile(manifest_path):
                     manifest = eval(open(manifest_path).read())
-                    print "Loading",i
+                    print "Loading", i
                     m = __import__(i)
                     addons_module[i] = m
                     addons_manifest[i] = manifest
-        for k,v in controllers_class.items():
+        for k, v in controllers_class.items():
             if k not in controllers_object:
                 o = v()
                 controllers_object[k] = o
-                if hasattr(o,'_cp_path'):
+                if hasattr(o, '_cp_path'):
                     controllers_path[o._cp_path] = o
 
     def default(self, *l, **kw):
         #print "default",l,kw
         # handle static files
-        if len(l) > 2 and l[1]=='static':
+        if len(l) > 2 and l[1] == 'static':
             # sanitize path
             p = os.path.normpath(os.path.join(*l))
-            return cherrypy.lib.static.serve_file(os.path.join(path_addons,p))
+            return cherrypy.lib.static.serve_file(os.path.join(path_addons, p))
         elif len(l) > 1:
-            for i in range(1,len(l)+1):
+            for i in range(1, len(l) + 1):
                 ps = "/" + "/".join(l[0:i])
                 if ps in controllers_path:
                     c = controllers_path[ps]
                     rest = l[i:] or ['index']
                     meth = rest[0]
-                    m = getattr(c,meth)
-                    if getattr(m,'exposed',0):
-                        print "Calling",ps,c,meth,m
+                    m = getattr(c, meth)
+                    if getattr(m, 'exposed', 0):
+                        print "Calling", ps, c, meth, m
                         return m(**kw)
 
         else:
             raise cherrypy.HTTPRedirect('/base/static/openerp/base.html', 301)
     default.exposed = True
 
+
 def main(argv):
     # Parse config
     op = optparse.OptionParser()
     op.add_option("-p", "--port", dest="socket_port", help="listening port", metavar="NUMBER", default=8002)
-    op.add_option("-s", "--session-path", dest="storage_path", help="directory used for session storage", metavar="DIR", default=os.path.join(tempfile.gettempdir(),"cpsessions"))
+    op.add_option("-s", "--session-path", dest="storage_path",
+                  help="directory used for session storage", metavar="DIR",
+                  default=os.path.join(tempfile.gettempdir(), "cpsessions"))
     (o, args) = op.parse_args(argv[1:])
 
     # Prepare cherrypy config from options

=== added directory 'openerpweb/tests'
=== added file 'openerpweb/tests/__init__.py'
--- openerpweb/tests/__init__.py	1970-01-01 00:00:00 +0000
+++ openerpweb/tests/__init__.py	2011-03-21 12:36:36 +0000
@@ -0,0 +1,1 @@
+# -*- coding: utf-8 -*-

=== added file 'openerpweb/tests/test_model.py'
--- openerpweb/tests/test_model.py	1970-01-01 00:00:00 +0000
+++ openerpweb/tests/test_model.py	2011-03-21 12:36:36 +0000
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+import mock
+import unittest2
+import openerpweb.openerpweb
+
+class OpenERPModelTest(unittest2.TestCase):
+    def test_rpc_call(self):
+        session = mock.Mock(['execute'])
+        Model = openerpweb.openerpweb.OpenERPModel(
+            session, 'a.b')
+
+        Model.search([('field', 'op', 'value')], {'key': 'value'})
+        session.execute.assert_called_once_with(
+            'a.b', 'search', [('field', 'op', 'value')], {'key': 'value'})
+
+        session.execute.reset_mock()
+        
+        Model.read([42])
+        session.execute.assert_called_once_with(
+            'a.b', 'read', [42])

=== modified file 'setup.py'
--- setup.py	2011-03-08 10:28:13 +0000
+++ setup.py	2011-03-21 12:36:36 +0000
@@ -59,6 +59,11 @@
         "simplejson >= 2.0.9",
         "python-dateutil >= 1.4.1",
     ],
+    tests_require=[
+        'unittest2',
+        'mock',
+    ],
+    test_suite = 'unittest2.collector',
     zip_safe=False,
     packages=[
         'addons',


Follow ups