diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5f4d2bf..c9fc5f7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,9 +5,9 @@ name: Odooly on: push: - branches: [ "master" ] + branches: [ "master", "dev" ] pull_request: - branches: [ "master" ] + branches: [ "master", "dev" ] jobs: build: diff --git a/CHANGES.rst b/CHANGES.rst index 2e9044e..ae37254 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,8 +5,22 @@ Changelog 2.x.x (unreleased) ~~~~~~~~~~~~~~~~~~ -* Use a Web session for JSON-RPC requests - when Requests is installed. +* Support webclient :class:`WebAPI` protocol as an alternative: + ``/web/dataset/*``, ``/web/database/*``, ... + Webclient API is stable since Odoo 9.0 + +* Authenticate with ``/web/session/authenticate`` by default + and retrieve :attr:`Env.session_info`, with Odoo >= 9.0. + +* Use Webclient API by default when ``protocol`` is not set. + It is same as setting ``protocol = web`` + +* New function :meth:`Client.drop_database`. + +* New functions to create/destroy a session: + :meth:`Env.session_authenticate` and :meth:`Env.session_destroy`. + +* Drop support for Python 3.5 2.2.1 (2025-09-24) diff --git a/README.rst b/README.rst index 578c4f1..3b83197 100644 --- a/README.rst +++ b/README.rst @@ -10,13 +10,13 @@ Download and install the latest release:: :local: :backlinks: top -Documentation and tutorial: http://odooly.readthedocs.org +Documentation and tutorial: https://odooly.readthedocs.io/ Overview -------- -Odooly carries three completing uses: +Odooly carries three modes of use: (1) with command line arguments (2) as an interactive shell @@ -25,14 +25,15 @@ Odooly carries three completing uses: Key features: -- provides an API very close to the Odoo API, through JSON-RPC and XML-RPC +- provides an API similar to Odoo Model, through Webclient API - compatible with OpenERP 6.1 through Odoo 19.0 -- single executable ``odooly.py``, no external dependency +- supports external APIs JSON-RPC and XML-RPC as alternative +- single file ``odooly.py``, no external dependency - helpers for ``search``, for data model introspection, etc... -- simplified syntax for search ``domain`` and ``fields`` -- full API accessible on the ``Client.env`` environment -- the module can be imported and used as a library: ``from odooly import Client`` -- supports Python 3.5 and above +- simplified syntax for search ``domain`` +- entire API accessible on the ``Client.env`` environment +- can be imported and used as a library: ``from odooly import Client`` +- supports Python 3.6 and more recent @@ -59,7 +60,7 @@ Although it is quite limited:: -c CONFIG, --config=CONFIG specify alternate config file (default: 'odooly.ini') --server=SERVER full URL of the server (default: - http://localhost:8069/xmlrpc) + http://localhost:8069/web) -d DB, --db=DB database -u USER, --user=USER username -p PASSWORD, --password=PASSWORD @@ -113,7 +114,7 @@ Edit ``odooly.ini`` and declare the environment(s):: [demo] username = demo password = demo - protocol = xmlrpc + protocol = web [demo_jsonrpc] username = demo @@ -164,6 +165,6 @@ This is a sample session:: .. note:: - To preserve the history of commands when closing the session, first + To preserve the commands' history when closing the session, first create an empty file in your home directory: ``touch ~/.odooly_history`` diff --git a/docs/api.rst b/docs/api.rst index 2a47de6..302f5bb 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -8,9 +8,9 @@ The library provides few objects to access the Odoo model and the associated services of `the Odoo API`_. The signature of the methods mimics the standard methods provided by the -:class:`osv.Model` Odoo class. This is intended to help the developer when -developping addons. What is experimented at the interactive prompt should -be portable in the application with little effort. +:class:`odoo.models.Model` Odoo class. This is intended to help the developer +when developping addons. What is experimented at the interactive prompt +should be portable in the application with little effort. .. contents:: :local: @@ -21,9 +21,10 @@ be portable in the application with little effort. Client and Services ------------------- -The :class:`Client` object provides thin wrappers around Odoo RPC services -and their methods. Additional helpers are provided to explore the models and -list or install Odoo add-ons. +The :class:`Client` object provides thin wrappers around Odoo Webclient API +and RPC services and their methods. Additional helpers are provided on the +``Client.env`` environment, to explore the models and to list or to install +Odoo add-ons. Please refer to :class:`Env` documentation below. .. autoclass:: Client @@ -34,8 +35,14 @@ list or install Odoo add-ons. .. automethod:: Client.clone_database +.. automethod:: Client.drop_database + .. automethod:: Client.login +.. attribute:: Client.env + + Current :class:`Env` environment of the client. + .. note:: @@ -50,16 +57,53 @@ list or install Odoo add-ons. ``ODOOLY_SSL_UNVERIFIED=1``. +Odoo Webclient API +~~~~~~~~~~~~~~~~~~ + +These HTTP routes were developed for the Odoo Web application. They are used +by Odooly to provide high level methods on :class:`Env` and :class:`Model`. +The :attr:`~Client.database` endpoint exposes few methods which might be helpful +for database management. Use :func:`dir` function to introspect them. + +.. attribute:: Client.database + + Expose the ``database`` :class:`WebAPI`. + + Example: :meth:`Client.database.list` method. + +.. attribute:: Client.web + + Expose the root ``/web`` :class:`WebAPI`. + +.. attribute:: Client.web_dataset + + Expose the ``/web/dataset`` :class:`WebAPI`. + +.. attribute:: Client.web_session + + Expose the ``/web/session`` :class:`WebAPI`. + +.. attribute:: Client.web_webclient + + Expose the ``/web/webclient`` :class:`WebAPI`. + + Odoo RPC Services ~~~~~~~~~~~~~~~~~ -The naked Odoo RPC services are exposed too. -The :attr:`~Client.db` and the :attr:`~Client.common` services expose few +.. note:: + + These RPC services are deprecated in Odoo 19.0. They are + scheduled for removal in Odoo 20.0. + +The Odoo RPC services are exposed too. They could be used for server and +database operations. +The :attr:`~Client.db` and the :attr:`~Client.common` services provided methods which might be helpful for server administration. Use the :func:`dir` function to introspect them. The :attr:`~Client._object` -service should not be used directly because its methods are wrapped and -exposed on the :class:`Env` object itself. -The two last services are deprecated and removed in recent versions of Odoo. +service should not be used directly. It provides same feature as the +:attr:`~Client.web_dataset` Webclient endpoint. Use :class:`Env` and :class:`Model` +instead. Please refer to `the Odoo documentation`_ for more details. @@ -70,16 +114,22 @@ Please refer to `the Odoo documentation`_ for more details. Examples: :meth:`Client.db.list` or :meth:`Client.db.server_version` RPC methods. + Removed in Odoo 20. + .. attribute:: Client.common Expose the ``common`` :class:`Service`. Example: :meth:`Client.common.login_message` RPC method. + Removed in Odoo 20. + .. data:: Client._object Expose the ``object`` :class:`Service`. + Removed in Odoo 20. + .. attribute:: Client._report Expose the ``report`` :class:`Service`. @@ -92,12 +142,16 @@ Please refer to `the Odoo documentation`_ for more details. Removed in OpenERP 7. +.. autoclass:: WebAPI + :members: + :undoc-members: + .. autoclass:: Service :members: :undoc-members: .. _the Odoo documentation: -.. _the Odoo API: http://doc.odoo.com/v6.1/developer/12_api.html#api +.. _the Odoo API: https://www.odoo.com/documentation/19.0/developer/reference/external_rpc_api.html Environment @@ -131,6 +185,16 @@ Environment Cursor on the current database. + .. automethod:: session_authenticate + + .. automethod:: session_destroy + + .. attribute:: session_info + + Dictionary returned when a Webclient session is authenticated. + It contains ``uid`` and ``user_context`` among other user's preferences + and server parameters. + .. automethod:: sudo(user=SUPERUSER_ID) @@ -151,6 +215,12 @@ Please refer to `the Odoo documentation`_ for details. .. automethod:: Env.execute(obj, method, *params, **kwargs) +.. automethod:: Env._call_kw(obj, method, *params, **kwargs) + +.. attribute:: Env._web(obj, method, *params, **kwargs) + + Expose the root of the ``/web`` API. + .. method:: Env.exec_workflow(obj, signal, obj_id) Wrapper around ``object.exec_workflow`` RPC method. @@ -212,7 +282,7 @@ Python script or interactively in a Python session. It is not recommended to install or upgrade modules in offline mode when any web server is still running: the operation will not be signaled to other processes. This restriction does not apply when connected through - XML-RPC or JSON-RPC. + Webclient API or other RPC API. .. _model-and-records: @@ -222,7 +292,7 @@ Model and Records The :class:`Env` provides a high level API similar to the Odoo API, which encapsulates objects into `Active Records -`_. +`_. The :class:`Model` is instantiated using the ``client.env[...]`` syntax. diff --git a/docs/developer.rst b/docs/developer.rst index 146196e..a8edf98 100644 --- a/docs/developer.rst +++ b/docs/developer.rst @@ -22,12 +22,13 @@ complex tasks. For example: * write unit tests using the standard `unittest - `_ framework. -* write BDD tests using the `Gherkin language `_, and a library - like `Behave `_. + `_ framework. +* write BDD tests using the `Gherkin language `_, and a library + like `Behave `_. * build an interface for Odoo, using a framework like - `Flask `_ (HTML, JSON, SOAP, ...). + `Flask `_ (HTML, JSON, SOAP, ...). + Changes diff --git a/docs/index.rst b/docs/index.rst index f83c75a..31c0868 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -10,11 +10,11 @@ Odooly's documentation *A versatile tool for browsing Odoo / OpenERP data* The Odooly library communicates with any `Odoo / OpenERP server`_ (>= 6.1) -using `the standard XML-RPC interface`_ or the new JSON-RPC interface. +using the Webclient API or `the deprecated external RPC interface`_ (JSON-RPC or XML-RPC). It provides both a :ref:`fully featured low-level API `, and an encapsulation of the methods on :ref:`Active Record objects -`. It implements the Odoo API 8.0. +`. It implements the Odoo API. Additional helpers are provided to explore the model and administrate the server remotely. @@ -35,11 +35,11 @@ Contents: tutorial developer -* Online documentation: http://odooly.readthedocs.org/ +* Online documentation: https://odooly.readthedocs.io/ * Source code and issue tracker: https://github.com/tinyerp/odooly -.. _Odoo / OpenERP server: http://doc.odoo.com/ -.. _the standard XML-RPC interface: http://doc.odoo.com/v6.1/developer/12_api.html#api +.. _Odoo / OpenERP server: https://www.odoo.com/documentation/ +.. _the deprecated external RPC interface: https://www.odoo.com/documentation/19.0/developer/reference/external_rpc_api.html Indices and tables diff --git a/docs/intro.rst b/docs/intro.rst index 8fae0c3..5eb87be 100644 --- a/docs/intro.rst +++ b/docs/intro.rst @@ -10,7 +10,7 @@ Installation ------------ Download and install the `latest release -`__ from PyPI:: +`__ from PyPI:: pip install -U odooly diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 237e402..a8ca67f 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -6,7 +6,7 @@ Tutorial This tutorial demonstrates some features of Odooly in the interactive shell. -It assumes an Odoo or OpenERP server is installed. +It assumes an Odoo server is installed. The shell is a true Python shell. We have access to all the features and modules of the Python interpreter. @@ -32,12 +32,17 @@ If our configuration is different, then we use arguments, like:: $ odooly --server http://192.168.0.42:8069 -It connects using the XML-RPC protocol. If you want to use the JSON-RPC -protocol instead, then pass the full URL with ``/jsonrpc`` path:: +It connects using the Webclient API. If you want to use the JSON-RPC +external API instead, then pass the full URL with ``/jsonrpc`` path:: $ odooly --server http://127.0.0.1:8069/jsonrpc +.. note:: + + These protocols JSON-RPC and XML-RPC are deprecated in Odoo 19.0 and will + be removed in Odoo 20.0. + On login, it prints few lines about the commands available. .. sourcecode:: pycon @@ -64,6 +69,7 @@ On login, it prints few lines about the commands available. env.install(module1, module2, ...) env.upgrade(module1, module2, ...) # Install or upgrade the modules + env.upgrade_cancel() # Reset failed upgrade/install And it confirms that the default database is not available:: @@ -73,9 +79,9 @@ And it confirms that the default database is not available:: Though, we have a connected client, ready to use:: >>> client - + >>> client.server_version - '6.1' + '18.0' >>> # @@ -96,8 +102,8 @@ Default password is ``"admin"``. >>> client.create_database('super_password', 'demo') Logged in as 'admin' >>> client - - >>> client.db.list() + + >>> client.database.list() ['demo'] >>> env @@ -137,8 +143,8 @@ database name and the superadmin password. >>> client.clone_database('super_password', 'demo_test') Logged in as 'admin' >>> client - - >>> client.db.list() + + >>> client.database.list() ['demo', 'demo_test'] >>> env @@ -160,7 +166,7 @@ Where is the table for the users? .. sourcecode:: pycon >>> client - + >>> env.models('user') ['res.users', 'res.users.log'] diff --git a/odooly.py b/odooly.py index 7fc94a7..26b5dba 100644 --- a/odooly.py +++ b/odooly.py @@ -18,14 +18,13 @@ from configparser import ConfigParser from threading import current_thread -from urllib.request import Request, urlopen +from urllib.parse import urljoin, urlencode from xmlrpc.client import Fault, ServerProxy, MININT, MAXINT try: import requests - http_session = requests.Session() except ImportError: - http_session = None + requests = None __version__ = '2.2.1' __all__ = ['Client', 'Env', 'Service', 'BaseModel', 'Model', @@ -34,7 +33,7 @@ CONF_FILE = 'odooly.ini' HIST_FILE = os.path.expanduser('~/.odooly_history') -DEFAULT_URL = 'http://localhost:8069/xmlrpc' +DEFAULT_URL = 'http://localhost:8069/web' DEFAULT_DB = 'odoo' DEFAULT_USER = 'admin' SUPERUSER_ID = 1 @@ -75,8 +74,17 @@ r'\s*(.*)') _fields_re = re.compile(r'(?:[^%]|^)%\(([^)]+)\)') +# Web methods (not exhaustive) +_web_methods = { + 'database': ['backup', 'change_password', 'create', 'drop', + 'duplicate', 'list', 'restore'], + 'dataset': ['call_button', 'call_kw'], + 'session': ['authenticate', 'check', 'destroy', 'get_lang_list', 'get_session_info'], + 'webclient': ['version_info'], +} + # Published object methods -_methods = { +_rpc_methods = { 'common': ['about', 'login', 'authenticate', 'version'], 'db': ['create_database', 'duplicate_database', 'db_exist', 'drop', 'dump', 'restore', 'rename', 'list', 'list_lang', 'list_countries', @@ -89,10 +97,9 @@ # New 7.0: (db) duplicate_database # New 9.0: (db) list_countries # No-op: (common) set_loglevel -# Remove 19.0: (common) version -# replaced by: GET /web/version (or (db) server_version) +# Remove 19.0: (common) version, replaced by GET /web/version -_obsolete_methods = { +_obsolete_rpc_methods = { 'common': [ 'check_connectivity', 'get_available_updates', 'get_migration_scripts', 'get_os_time', 'get_stats', @@ -109,9 +116,23 @@ "of the following exception:\n\n") _pending_state = ('state', 'not in', ['uninstallable', 'uninstalled', 'installed']) +http_context = None seq_types = (list, tuple) +if os.getenv('ODOOLY_SSL_UNVERIFIED'): + import ssl + + def ServerProxy(url, transport, allow_none, _ServerProxy=ServerProxy): + return _ServerProxy(url, transport=transport, allow_none=allow_none, + context=ssl._create_unverified_context()) + http_context = ssl._create_unverified_context() + requests = None + +if not requests: + from urllib.request import HTTPCookieProcessor, HTTPSHandler, Request, build_opener + + def _memoize(inst, attr, value, doc_values=None): if hasattr(value, '__get__') and not hasattr(value, '__self__'): value.__name__ = attr @@ -211,7 +232,7 @@ def format_exception(exc_type, exc, tb, limit=None, chain=True, message = str(server_error['arguments'][0]) except Exception: message = str(server_error['arguments']) - fault = '%s: %s' % (server_error['name'], message) + fault = f"{server_error['name']}: {message}" exc_type = server_error.get('exception_type', 'internal_error') if exc_type != 'internal_error' or message.startswith('FATAL:'): server_tb = None @@ -246,8 +267,8 @@ def read_config(section=None): if scheme == 'local': server = shlex.split(env.get('options', '')) else: - protocol = env.get('protocol', 'xmlrpc') - server = '%s://%s:%s/%s' % (scheme, env['host'], env['port'], protocol) + protocol = env.get('protocol', 'web') + server = f"{scheme}://{env['host']}:{env['port']}/{protocol}" return (server, env['database'], env['username'], env.get('password')) @@ -320,7 +341,7 @@ def searchargs(params, kwargs=None): if isinstance(term, str) and term not in DOMAIN_OPERATORS: m = _term_re.match(term.strip()) if not m: - raise ValueError('Cannot parse term %r' % term) + raise ValueError(f"Cannot parse term {term!r}") (field, operator, value) = m.groups() try: value = literal_eval(value) @@ -338,48 +359,12 @@ def searchargs(params, kwargs=None): return params -if os.getenv('ODOOLY_SSL_UNVERIFIED'): - import ssl - - def urlopen(url, _urlopen=urlopen): - return _urlopen(url, context=ssl._create_unverified_context()) - - def ServerProxy(url, transport, allow_none, _ServerProxy=ServerProxy): - return _ServerProxy(url, transport=transport, allow_none=allow_none, - context=ssl._create_unverified_context()) - http_session = None - - -if http_session: - def http_post(url, data, headers={'Content-Type': 'application/json'}): - resp = http_session.post(url, data=data, headers=headers) - return resp.json() -else: - def http_post(url, data, headers={'Content-Type': 'application/json'}): - request = Request(url, data=data, headers=headers) - resp = urlopen(request) - return json.load(resp) - - -def dispatch_jsonrpc(url, service_name, method, args): - data = { - 'jsonrpc': '2.0', - 'method': 'call', - 'params': {'service': service_name, 'method': method, 'args': args}, - 'id': '%04x%010x' % (os.getpid(), (int(time.time() * 1E6) % 2**40)), - } - resp = http_post(url, json.dumps(data).encode('ascii')) - if resp.get('error'): - raise ServerError(resp['error']) - return resp.get('result') - - class partial(functools.partial): __slots__ = () def __repr__(self): # Hide arguments - return '%s(%r, ...)' % (self.__class__.__name__, self.func) + return f"{self.__class__.__name__}({self.func!r}, ...)" class Error(Exception): @@ -390,13 +375,69 @@ class ServerError(Exception): """An error received from the server.""" +class WebAPI: + """A wrapper around Web endpoints. + + The connected endpoints are exposed on the Client instance. + Argument `client` is the connected Client. + Argument `endpoint` is the name of the service + (examples: ``"database"``, ``"session"``). + Argument `methods` is the list of methods which should be + exposed on this endpoint. Use ``dir(...)`` on the + instance to list them. + """ + _methods = () + + def __init__(self, client, endpoint, methods, verbose=False): + self._dispatch = client._proxy_web(endpoint) + self._server = urljoin(client._server, '/') + self._endpoint = f'/web/{endpoint}' if endpoint else '/web' + self._methods = methods + self._verbose = verbose + + def __repr__(self): + return f"" + __str__ = __repr__ + + def __dir__(self): + return sorted(self._methods) + + def __getattr__(self, name): + if self._verbose: + def sanitize(kw): + secret = {} + for key in kw: + if 'passw' in key or 'pwd' in key: + secret[key] = ... + return {**kw, **secret}.items() + maxcol = MAXCOL[min(len(MAXCOL), self._verbose) - 1] + + def wrapper(self, **params): + snt = ' '.join(f'{key}={v!r}' if v != ... else f'{key}=*' + for (key, v) in sanitize(params)) + snt = f"POST {self._endpoint}/{name} {snt}" + if len(snt) > maxcol: + suffix = f"... L={len(snt)}" + snt = snt[:maxcol - len(suffix)] + suffix + print(f"--> {snt}") + res = self._dispatch(name, params) + rcv = repr(res) + if len(rcv) > maxcol: + suffix = f"... L={len(rcv)}" + rcv = rcv[:maxcol - len(suffix)] + suffix + print(f"<-- {rcv}") + return res + else: + wrapper = lambda s, **params: s._dispatch(name, params) + return _memoize(self, name, wrapper) + + class Service: """A wrapper around RPC endpoints. The connected endpoints are exposed on the Client instance. - The `server` argument is the URL of the server (scheme+host+port). - If `server` is an ``odoo`` Python package, it is used to connect to the - local server. The `endpoint` argument is the name of the service + The `client` argument is the connected Client. + The `endpoint` argument is the name of the service (examples: ``"object"``, ``"db"``). The `methods` is the list of methods which should be exposed on this endpoint. Use ``dir(...)`` on the instance to list them. @@ -411,7 +452,7 @@ def __init__(self, client, endpoint, methods, verbose=False): self._verbose = verbose def __repr__(self): - return "" % (self._rpcpath, self._endpoint) + return f"" __str__ = __repr__ def __dir__(self): @@ -419,7 +460,7 @@ def __dir__(self): def __getattr__(self, name): if name not in self._methods: - raise AttributeError("'Service' object has no attribute %r" % name) + raise AttributeError(f"'Service' object has no attribute {name!r}") if self._verbose: def sanitize(args): if self._endpoint != 'db' and len(args) > 2: @@ -429,18 +470,18 @@ def sanitize(args): maxcol = MAXCOL[min(len(MAXCOL), self._verbose) - 1] def wrapper(self, *args): - snt = ', '.join([repr(arg) for arg in sanitize(args)]) - snt = '%s.%s(%s)' % (self._endpoint, name, snt) + snt = ', '.join(repr(arg) for arg in sanitize(args)) + snt = f"{self._endpoint}.{name}({snt})" if len(snt) > maxcol: - suffix = '... L=%s' % len(snt) + suffix = f"... L={len(snt)}" snt = snt[:maxcol - len(suffix)] + suffix - print('--> ' + snt) + print(f"--> {snt}") res = self._dispatch(name, args) rcv = str(res) if len(rcv) > maxcol: - suffix = '... L=%s' % len(rcv) + suffix = f"... L={len(rcv)}" rcv = rcv[:maxcol - len(suffix)] + suffix - print('<-- ' + rcv) + print(f"<-- {rcv}") return res else: wrapper = lambda s, *args: s._dispatch(name, args) @@ -459,7 +500,7 @@ class Env: >>> env["some.model"] """ - name = uid = user = None + name = uid = user = session_info = None _cache = {} def __new__(cls, client, db_name=()): @@ -471,6 +512,8 @@ def __new__(cls, client, db_name=()): if db_name: env._model_names = env._cache_get('model_names', set) env._models = {} + env._call_kw = client._call_kw + env._web = client.web return env def __contains__(self, name): @@ -497,8 +540,7 @@ def __bool__(self): __hash__ = object.__hash__ def __repr__(self): - return "" % (self.user.login if self.uid else '', - self.db_name) + return f"" def check_uid(self, uid, password): """Check if ``(uid, password)`` is valid. @@ -506,6 +548,8 @@ def check_uid(self, uid, password): Return ``uid`` on success, ``False`` on failure. The invalid entry is removed from the authentication cache. """ + if not self.client._object: + return False try: self.client._object.execute_kw(self.db_name, uid, password, 'ir.model', 'fields_get', ([None],)) @@ -516,11 +560,11 @@ def check_uid(self, uid, password): uid = False return uid - def _auth(self, user, password): + def _auth(self, user, password, context): assert self.db_name, 'Not connected' uid = verified = None if isinstance(user, int): - (user, uid) = (uid, user) + (user, uid) = (None, user) auth_cache = self._cache_get('auth', dict) if not password: # Read from cache @@ -541,33 +585,46 @@ def _auth(self, user, password): if not password and uid is not False: from getpass import getpass if user is None: - name = 'admin' if uid == SUPERUSER_ID else ('UID %d' % uid) + name = 'admin' if uid == SUPERUSER_ID else f'UID {uid}' else: name = user - password = getpass('Password for %r: ' % name) + password = getpass(f"Password for {name!r}: ") # Check if password is valid uid = self.check_uid(uid, password) if (uid and not verified) else uid if uid is None: # Do a standard 'login' try: - uid = self.client.common.login(self.db_name, user, password) + info = self.client._authenticate(self.db_name, user, password) + uid = info['uid'] except Exception as exc: if 'does not exist' in str(exc): # Heuristic raise Error('Database does not exist') raise + else: + info = {'uid': uid} if not uid: raise Error('Invalid username or password') # Update the cache auth_cache[uid] = (uid, password) if user: auth_cache[user] = auth_cache[uid] - return (uid, password) + self.session_info = info + user_context = info.get('user_context') or {} + if not user_context and self.client._object: + user_context = self.client._object.execute_kw(self.db_name, uid, password, 'res.users', 'context_get') + if context: + user_context = {**user_context, **context} + return (uid, password, user_context) def _set_credentials(self, uid, password): def env_auth(method): # Authenticated endpoints return partial(method, self.db_name, uid, password) - self._execute = env_auth(self.client._object.execute) - self._execute_kw = env_auth(self.client._object.execute_kw) + if self.client._object: + self._execute = env_auth(self.client._object.execute) + self._execute_kw = env_auth(self.client._object.execute_kw) + else: # WebAPI + assert self.client.web + self._execute_kw = self._call_kw if self.client._report: # Odoo <= 10 self.exec_workflow = env_auth(self.client._object.exec_workflow) self.report = env_auth(self.client._report.report) @@ -581,7 +638,6 @@ def _configure(self, uid, user, password, context): if self.uid: # Create a new Env() instance env = Env(self.client) (env.db_name, env.name) = (self.db_name, self.name) - env.context = dict(context) env._model_names = self._model_names env._models = {} else: # Configure the Env() instance @@ -589,7 +645,8 @@ def _configure(self, uid, user, password, context): if uid == self.uid: # Copy methods for key in ('_execute', '_execute_kw', 'exec_workflow', 'report', 'report_get', 'render_report', - 'wizard_execute', 'wizard_create'): + 'wizard_execute', 'wizard_create', + 'session_info'): if hasattr(self, key): setattr(env, key, getattr(self, key)) else: # Create methods @@ -601,6 +658,7 @@ def _configure(self, uid, user, password, context): user = user.login env.uid = uid env.user = env._get('res.users', False).browse(uid) + env.context = dict(context) if user: assert isinstance(user, str), repr(user) env.user.__dict__['login'] = user @@ -632,7 +690,7 @@ def registry(self): def __call__(self, user=None, password=None, context=None): """Return an environment based on ``self`` with modified parameters.""" if user is not None: - (uid, password), context = self._auth(user, password), {} + (uid, password, context) = self._auth(user, password, context) elif context is not None: (uid, user) = (self.uid, self.user) else: @@ -784,7 +842,7 @@ def _get(self, name, check=True): if model_names: errmsg = 'Model not found. These models exist:' else: - errmsg = 'Model not found: %s' % (name,) + errmsg = f'Model not found: {name}' raise Error('\n * '.join([errmsg] + model_names)) def modules(self, name='', installed=None): @@ -820,7 +878,7 @@ def _upgrade(self, modules, button): ir_module = self._get('ir.module.module', False) updated, added = ir_module.update_list() if added: - print('%s module(s) added to the list' % added) + print(f'{added} module(s) added to the list') # Find modules sel = modules and ir_module.search([('name', 'in', modules)]) mods = ir_module.read([_pending_state], 'name state') @@ -828,7 +886,7 @@ def _upgrade(self, modules, button): # Safety check if any(mod['name'] not in modules for mod in mods): raise Error('Pending actions:\n' + '\n'.join( - (' %(state)s\t%(name)s' % mod) for mod in mods)) + f" {mod['state']}\t{mod['name']}" for mod in mods)) if button == 'button_uninstall': # Safety check names = ir_module.read([('id', 'in', sel.ids), @@ -836,7 +894,7 @@ def _upgrade(self, modules, button): 'state != to upgrade', 'state != to remove'], 'name') if names: - raise Error('Not installed: %s' % ', '.join(names)) + raise Error(f"Not installed: {', '.join(names)}") if self.client.version_info < 7.0: # A trick to uninstall dependent add-ons sel.write({'state': 'to remove'}) @@ -849,13 +907,13 @@ def _upgrade(self, modules, button): print('Already up-to-date: %s' % self.modules([('id', 'in', sel.ids)])) elif modules: - raise Error('Module(s) not found: %s' % ', '.join(modules)) - print('%s module(s) updated' % updated) + raise Error(f"Module(s) not found: {', '.join(modules)}") + print(f'{updated} module(s) updated') return - print('%s module(s) selected' % len(sel)) - print('%s module(s) to process:' % len(mods)) + print(f'{len(sel)} module(s) selected') + print(f'{len(mods)} module(s) to process:') for mod in mods: - print(' %(state)s\t%(name)s' % mod) + print(f" {mod['state']}\t{mod['name']}") # Empty the cache for this database self.refresh() @@ -873,21 +931,42 @@ def _upgrade(self, modules, button): self.execute('base.module.upgrade', 'upgrade_module', []) def upgrade(self, *modules): - """Press the button ``Upgrade``.""" + """Press button ``Upgrade``.""" return self._upgrade(modules, button='button_upgrade') def install(self, *modules): - """Press the button ``Install``.""" + """Press button ``Install``.""" return self._upgrade(modules, button='button_install') def uninstall(self, *modules): - """Press the button ``Uninstall``.""" + """Press button ``Uninstall``.""" return self._upgrade(modules, button='button_uninstall') def upgrade_cancel(self, *modules): - """Press the button ``Cancel Upgrade/Install/Uninstall``.""" + """Press button ``Cancel Upgrade/Install/Uninstall``.""" return self._upgrade(modules, button='cancel') + def session_authenticate(self, **kwargs): + """Create a Webclient session for current user.""" + auth_cached = self._cache_get('auth').get(self.uid) + params = { + 'db': self.db_name, + 'login': self.user and self.user.login, + 'password': auth_cached and auth_cached[1], + **kwargs, + } + self.session_info = self.client.web_session.authenticate(**params) + + def session_destroy(self): + """Terminate current Webclient session.""" + self.session_info = None + try: + return self.client.web_session.destroy() + except ServerError as exc: + # Ignore: odoo.http.SessionExpiredException + if exc.args[0]['code'] != 100: + raise + class Client: """Connection to an Odoo instance. @@ -906,7 +985,6 @@ class Client: def __init__(self, server, db=None, user=None, password=None, transport=None, verbose=False): - self._connections = [] self._set_services(server, transport, verbose) self.env = Env(self) if db: # Try to login @@ -918,32 +996,59 @@ def _set_services(self, server, transport, verbose): server = start_odoo_services(server, appname=appname) elif isinstance(server, str) and server[-1:] == '/': server = server.rstrip('/') - self._server = server + + def get_web_api(name): + methods = list(_web_methods.get(name) or []) + return WebAPI(self, name, methods, verbose=verbose) + + def get_service(name): + methods = list(_rpc_methods.get(name) or []) + if float_version < 11.0: + methods += _obsolete_rpc_methods.get(name) or () + return Service(self, name, methods, verbose=verbose) if not isinstance(server, str): - assert not transport, 'Not supported' api_v7 = server.release.version_info < (8,) - self._proxy = self._proxy_v7 if api_v7 else self._proxy_dispatch + self._proxy = self._proxy_v7 if api_v7 else self._proxy_odoo + elif '/xmlrpc' in server: + self._proxy = self._proxy_xmlrpc + self._transport = transport elif '/jsonrpc' in server: - assert not transport, 'Not supported' self._proxy = self._proxy_jsonrpc else: - if '/xmlrpc' not in server: - self._server = server + '/xmlrpc' - self._proxy = self._proxy_xmlrpc - self._transport = transport + if '/web' not in server: + server = urljoin(server, '/web') + self._proxy = None - def get_service(name): - methods = list(_methods[name]) if (name in _methods) else [] - if float_version < 11.0: - methods += _obsolete_methods.get(name) or () - return Service(self, name, methods, verbose=verbose) + self._connections = [] + self._server = server + self._verbose = verbose + assert not transport or self._proxy is self._proxy_xmlrpc, 'Not supported' + + if isinstance(server, str): + self._set_http_session() + self.web = get_web_api(None) + self.database = get_web_api('database') + self.web_dataset = get_web_api('dataset') + self.web_session = get_web_api('session') + self.web_webclient = get_web_api('webclient') + else: + self.web = None + + if self._proxy is None: + ver = self.web_webclient.version_info() + [major, minor] = ver["server_version_info"][:2] + self.server_version = ver["server_version"] + self.version_info = float(f"{major}.{minor}") + self.db = self.common = None + self._object = self._report = self._wizard = None + return float_version = 99.0 self.server_version = ver = get_service('db').server_version() self.major_version = re.search(r'\d+\.?\d*', ver).group() self.version_info = float_version = float(self.major_version) - assert float_version > 6.0, 'Not supported: %s' % ver + assert float_version > 6.0, f'Not supported: {ver}' # Create the RPC services self.db = get_service('db') self.common = get_service('common') @@ -951,7 +1056,15 @@ def get_service(name): self._report = get_service('report') if float_version < 11.0 else None self._wizard = get_service('wizard') if float_version < 7.0 else None - def _proxy_dispatch(self, name): + def _post_jsonrpc(self, endpoint='', params=None): + req_id = f"{os.getpid():04x}{int(time.time() * 1E6) % 2**40:010x}" + payload = {'jsonrpc': '2.0', 'method': 'call', 'params': params or {}, 'id': req_id} + resp = self._post(urljoin(self._server, endpoint), json=payload) + if resp.get('error'): + raise ServerError(resp['error']) + return resp.get('result') + + def _proxy_odoo(self, name): return partial(self._server.http.dispatch_rpc, name) def _proxy_v7(self, name): @@ -964,7 +1077,16 @@ def _proxy_xmlrpc(self, name): return proxy._ServerProxy__request def _proxy_jsonrpc(self, name): - return partial(dispatch_jsonrpc, self._server, name) + def dispatch_jsonrpc(method, args): + return self._post_jsonrpc(params={'service': name, 'method': method, 'args': args}) + return dispatch_jsonrpc + + def _proxy_web(self, name): + def dispatch_web(method, params): + if name == 'database' and method != 'list': + return self._post(urljoin(self._server, f"web/{name or ''}/{method}"), data=params) + return self._post_jsonrpc(f"web/{name or ''}/{method}", params=params) + return dispatch_web @classmethod def from_config(cls, environment, user=None, verbose=False): @@ -983,13 +1105,22 @@ def from_config(cls, environment, user=None, verbose=False): return client def __repr__(self): - return "" % (self._server, self.env.db_name) + return f"" def close(self): for conn in self._connections: conn.__exit__() self._connections = [] + def _authenticate(self, db, login, password): + if self.web and self.version_info > 8.0: + info = self.web_session.authenticate(db=db, login=login, password=password) + elif self.common: + info = {'uid': self.common.login(db, login, password)} + else: + raise Error("Cannot authenticate") + return info + def _login(self, user, password=None, database=None): """Switch `user` and (optionally) `database`. @@ -998,7 +1129,7 @@ def _login(self, user, password=None, database=None): env = self.env if database: try: - dbs = self.db.list() + dbs = self.db.list() if self.db else self.database.list() except Exception: pass # AccessDenied: simply ignore this check else: @@ -1012,11 +1143,10 @@ def _login(self, user, password=None, database=None): elif not env.db_name: raise Error('Not connected') try: - env = env(user=user, password=password) + self.env = env(user=user, password=password) except Exception: current_thread().dbname = self.env.db_name raise - self.env = env(context=env['res.users'].context_get()) return env.uid def login(self, user, password=None, database=None): @@ -1026,7 +1156,7 @@ def login(self, user, password=None, database=None): try: self._login(user, password=password, database=database) except Error as exc: - print('%s: %s' % (exc.__class__.__name__, exc)) + print(f"{exc.__class__.__name__}: {exc}") else: # Register the new globals() self.connect() @@ -1035,7 +1165,7 @@ def connect(self, env_name=None): """Connect to another environment and replace the globals().""" assert self._globals, 'Not available' if env_name: - self.from_config(env_name, verbose=self.db._verbose) + self.from_config(env_name, verbose=self._verbose) return client = self env_name = client.env.name or client.env.db_name @@ -1043,11 +1173,11 @@ def connect(self, env_name=None): self._globals['env'] = client.env self._globals['self'] = client.env.user if client.env.uid else None # Tweak prompt - sys.ps1 = '%s >>> ' % (env_name,) + sys.ps1 = f'{env_name} >>> ' sys.ps2 = '... '.rjust(len(sys.ps1)) # Logged in? if client.env.uid: - print('Logged in as %r' % (client.env.user.login,)) + print(f'Logged in as {client.env.user.login!r}') @classmethod def _set_interactive(cls, global_vars={}): @@ -1070,17 +1200,19 @@ def create_database(self, passwd, database, demo=False, lang='en_US', and no country is set into the database. Login if successful. """ - if login == 'admin' and not country_code: - self.db.create_database(passwd, database, demo, lang, - user_password) - elif self.version_info < 9.0: + extra = (login, country_code) if login != 'admin' or country_code else () + if extra and self.version_info < 9.0: raise Error("Custom 'login' and 'country_code' are not supported") - else: + if self.db: self.db.create_database(passwd, database, demo, lang, - user_password, login, country_code) + user_password, *extra) + else: + self.database.create(master_pwd=passwd, name=database, lang=lang, + password=user_password, demo=demo, login=login, + country_code=country_code, phone='') return self.login(login, user_password, database=database) - def clone_database(self, passwd, database): + def clone_database(self, passwd, database, neutralize_database=False): """Clone the current database. The superadmin `passwd` and `database` are mandatory. @@ -1088,15 +1220,63 @@ def clone_database(self, passwd, database): Supported since OpenERP 7. """ - self.db.duplicate_database(passwd, self.env.db_name, database) + extra = (neutralize_database,) if neutralize_database else () + if extra and self.version_info < 16.0: + raise Error("Argument 'neutralize_database' is not supported") + if self.db: + self.db.duplicate_database(passwd, self.env.db_name, database, *extra) + else: + extra = {"neutralize_database": extra[0]} if extra else {} + self.database.duplicate(master_pwd=passwd, name=self.env.db_name, + new_name=database, **extra) # Copy the cache for authentication auth_cache = self.env._cache_get('auth') self.env._cache_set('auth', dict(auth_cache), db_name=database) # Login with the current user into the new database - (uid, password) = self.env._auth(self.env.uid, None) + (uid, password, context) = self.env._auth(self.env.uid, None, None) return self.login(self.env.user.login, password, database=database) + def drop_database(self, passwd, database): + """Drop the database. + + The superadmin `passwd` and `database` are mandatory. + """ + if not database or database == self.env.db_name: + raise Error("Cannot delete active database") + if self.db: + self.db.drop(passwd, database) + db_list = self.db.list() + else: + self.database.drop(master_pwd=passwd, name=database) + db_list = self.database.list() + if database in db_list: + raise Error("Unsuccessful - Database was not deleted") + + def _call_kw(self, model, method, args, kw=None): + return self.web_dataset.call_kw(model=model, method=method, args=args, kwargs=kw or {}) + + if requests: # requests.Session + def _set_http_session(self): + self._http_session = requests.Session() + + def _post(self, url, *, data=None, json=None, headers=None, **kw): + resp = self._http_session.post(url, data=data, json=json, headers=headers, **kw) + return resp.json() if json else resp.text + + else: # urllib.request + def _set_http_session(self): + self._http_session = build_opener(HTTPCookieProcessor(), HTTPSHandler(context=http_context)) + + def _post(self, url, *, data=None, json=None, headers=None, __json=json, **kw): + headers = dict(headers or ()) + if json: + headers.setdefault('Content-Type', 'application/json') + data = __json.dumps(json).encode('ascii') if json else urlencode(data).encode('utf-8') + request = Request(url, data=data, headers=headers) + resp = self._http_session.open(request) + return __json.load(resp) if json else resp.read() + class BaseModel: @@ -1138,7 +1318,7 @@ def _new(cls, env, name): return m def __repr__(self): - return "" % (self._name,) + return f"" def keys(self): """Return the keys of the model.""" @@ -1220,12 +1400,12 @@ def get(self, domain, *args, **kwargs): rec = self.env.ref(domain) if not rec: return None - assert rec._model is self, 'Model mismatch %r %r' % (rec, self) + assert rec._model is self, f'Model mismatch {rec!r} {self!r}' return rec assert issearchdomain(domain) # a search domain ids = self._execute('search', domain) if len(ids) > 1: - raise ValueError('domain matches too many records (%d)' % len(ids)) + raise ValueError(f'domain matches too many records ({len(ids)})') return Record(self, ids[0]) if ids else None def create(self, values): @@ -1327,7 +1507,7 @@ def _unbrowse_values(self, values): field_type = self._fields[key]['type'] if hasattr(value, 'id'): if field_type == 'reference': - new_values[key] = '%s,%s' % (value._name, value.id) + new_values[key] = f'{value._name},{value.id}' else: new_values[key] = value = value.id if field_type in ('one2many', 'many2many'): @@ -1350,7 +1530,7 @@ def _get_external_ids(self, ids=None): ['module', 'name', 'res_id']) res = {} for rec in existing: - res['%(module)s.%(name)s' % rec] = self.get(rec['res_id']) + res[f"{rec['module']}.{rec['name']}"] = self.get(rec['res_id']) return res def __getattr__(self, attr): @@ -1363,7 +1543,7 @@ def __getattr__(self, attr): if attr == '_keys': return _memoize(self, attr, sorted(self._fields)) if attr.startswith('_'): - raise AttributeError("'Model' object has no attribute %r" % attr) + raise AttributeError(f"'Model' object has no attribute {attr!r}") def wrapper(self, *params, **kwargs): """Wrapper for client.execute(%r, %r, *params, **kwargs).""" @@ -1412,11 +1592,10 @@ def __new__(cls, res_model, arg): def __repr__(self): if len(self.ids) > 16: - ids = 'length=%d' % len(self.ids) + ids = f'length={len(self.ids)}' else: ids = self.id - return "<%s '%s,%s'>" % (self.__class__.__name__, - self._name, ids) + return f"<{self.__class__.__name__} '{self._name},{ids}'>" def __dir__(self): attrs = set(self.__dict__) | set(self._model._keys) @@ -1511,7 +1690,7 @@ def ensure_one(self): recs = self.union() if len(recs.id) == 1: return recs[0] - raise ValueError("Expected singleton: %s" % self) + raise ValueError(f"Expected singleton: {self}") def exists(self): """Return a subset of records that exist.""" @@ -1720,7 +1899,7 @@ def __getattr__(self, attr): if attr in self._model._keys: return self.read(attr) if attr.startswith('_'): - errmsg = "'RecordList' object has no attribute %r" % attr + errmsg = f"'RecordList' object has no attribute {attr!r}" raise AttributeError(errmsg) def wrapper(self, *params, **kwargs): @@ -1730,10 +1909,10 @@ def wrapper(self, *params, **kwargs): def __setattr__(self, attr, value): if attr in self._model._keys or attr == 'id': - msg = "attribute %r is read-only; use 'RecordList.write' instead." + msg = f"attribute {attr!r} is read-only; use 'RecordList.write' instead." else: - msg = "has no attribute %r" - raise AttributeError("'RecordList' object %s" % msg % attr) + msg = f"has no attribute {attr!r}" + raise AttributeError("'RecordList' object " + msg) def __eq__(self, other): return (isinstance(other, RecordList) and @@ -1759,9 +1938,9 @@ def __str__(self): def _get_name(self): try: (id_name,) = self._execute('name_get', [self.id]) - name = '%s' % (id_name[1],) + name = f'{id_name[1]}' except Exception: - name = '%s,%d' % (self._name, self.id) + name = f'{self._name},{self.id}' self.__dict__['_idnames'] = [(self.id, name)] return _memoize(self, '_Record__name', name) @@ -1827,7 +2006,7 @@ def _set_external_id(self, xml_id): domain = ['|', '&', ('module', '=', mod), ('name', '=', name), '&', ('model', '=', self._name), ('res_id', '=', self.id)] if self.env['ir.model.data'].search(domain): - raise ValueError('ID %r collides with another entry' % xml_id) + raise ValueError(f'ID {xml_id!r} collides with another entry') self.env['ir.model.data'].create({ 'model': self._name, 'res_id': self.id, @@ -1841,7 +2020,7 @@ def __getattr__(self, attr): if attr == '_Record__name': return self._get_name() if attr.startswith('_'): - raise AttributeError("'Record' object has no attribute %r" % attr) + raise AttributeError(f"'Record' object has no attribute {attr!r}") def wrapper(self, *params, **kwargs): """Wrapper for client.execute(%r, %r, %d, *params, **kwargs).""" @@ -1856,7 +2035,7 @@ def __setattr__(self, attr, value): if attr == '_external_id': return self._set_external_id(value) if attr not in self._model._keys: - raise AttributeError("'Record' object has no attribute %r" % attr) + raise AttributeError(f"'Record' object has no attribute {attr!r}") if attr == 'id': raise AttributeError("'Record' object attribute 'id' is read-only") self.write({attr: value}) @@ -1933,10 +2112,10 @@ def main(interact=_interact): help='read connection settings from the given section') parser.add_option( '-c', '--config', default=None, - help='specify alternate config file (default: %r)' % CONF_FILE) + help=f'specify alternate config file (default: {CONF_FILE!r})') parser.add_option( '--server', default=None, - help='full URL of the server (default: %s)' % DEFAULT_URL) + help=f'full URL of the server (default: {DEFAULT_URL})') parser.add_option('-d', '--db', default=DEFAULT_DB, help='database') parser.add_option('-u', '--user', default=None, help='username') parser.add_option( diff --git a/tests/_common.py b/tests/_common.py index 2e8745c..11f21e0 100644 --- a/tests/_common.py +++ b/tests/_common.py @@ -21,17 +21,17 @@ def OBJ(model, method, *params, **kw): kw['context'] = sample_context elif kw['context'] is None: del kw['context'] - return ('object.execute_kw', sentinel.AUTH, model, method, params) + ((kw,) if kw else ()) + extra = (params, kw) if kw else (params,) if params else () + return ('object.execute_kw', sentinel.AUTH, model, method, *extra) class XmlRpcTestCase(TestCase): server_version = None - server = None + server = "http://192.0.2.199:9999" database = user = password = uid = None maxDiff = None def setUp(self): - self.maxDiff = 4096 # instead of 640 self.addCleanup(mock.patch.stopall) self.stdout = mock.patch('sys.stdout', new=PseudoFile()).start() self.stderr = mock.patch('sys.stderr', new=PseudoFile()).start() @@ -44,6 +44,8 @@ def setUp(self): self.service = self._patch_service() if self.server and self.database: + if float(self.server_version) > 8.0: + self._patch_http_post() # create the client self.client = odooly.Client( self.server, self.database, self.user, self.password) @@ -51,6 +53,15 @@ def setUp(self): # reset the mock self.service.reset_mock() + def _patch_http_post(self, uid=None, context=sample_context): + def func(url, *, data=None, json=None): + if url.endswith("/web/session/authenticate"): + result = {'uid': uid or self.uid, 'user_context': context} + else: + raise RuntimeError + return {'result': result} + return mock.patch('odooly.Client._post', side_effect=func).start() + def _patch_service(self): def get_svc(server, name, *args, **kwargs): return getattr(svcs, name) diff --git a/tests/test_client.py b/tests/test_client.py index b9923af..c60ddb3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -42,15 +42,20 @@ def __eq__(self, other): class TestService(XmlRpcTestCase): """Test the Service class.""" protocol = 'xmlrpc' + uid = 22 def _patch_service(self): + self._patch_http_post() return mock.patch('odooly.ServerProxy._ServerProxy__request').start() def _get_client(self): - client = mock.Mock() - client._server = 'http://127.0.0.1:8069/%s' % self.protocol proxy = getattr(odooly.Client, '_proxy_%s' % self.protocol) + client = mock.Mock() + client._server = f"{self.server}/{self.protocol}" client._proxy = proxy.__get__(client, odooly.Client) + client._post_jsonrpc = odooly.Client._post_jsonrpc.__get__(client, odooly.Client) + if self.protocol == 'jsonrpc': + client._post = self.service return client def test_service(self): @@ -71,7 +76,7 @@ def test_service_openerp(self): def get_proxy(name, methods=None): if methods is None: - methods = odooly._methods.get(name, ()) + methods = odooly._rpc_methods.get(name, ()) return odooly.Service(client, name, methods, verbose=False) self.assertIn('common', str(get_proxy('common').login)) @@ -82,14 +87,17 @@ def get_proxy(name, methods=None): self.assertIn('_ServerProxy__request', str(login)) self.assertCalls(call('login', ('aaa',)), 'call().__str__') else: - self.assertEqual(login, 'JSON_RESULT',) - self.assertCalls(ANY) + params = {'service': 'common', 'method': 'login', 'args': ('aaa',)} + self.assertCalls(call(ANY, json={'jsonrpc': '2.0', 'method': 'call', 'params': params, 'id': ANY})) + self.assertEqual(login, 'JSON_RESULT') self.assertOutput('') def test_service_openerp_client(self, server_version=11.0): - server = 'http://127.0.0.1:8069/%s' % self.protocol + server = f"{self.server}/{self.protocol}" return_values = [str(server_version), ['newdb'], 1, {}] if self.protocol == 'jsonrpc': + if server_version > 8.0: + return_values[2:] = [{'uid': 22, 'user_context': {'lang': 'it_IT'}}] return_values = [{'result': rv} for rv in return_values] self.service.side_effect = return_values client = odooly.Client(server, 'newdb', 'usr', 'pss') @@ -111,15 +119,26 @@ def test_service_openerp_client(self, server_version=11.0): self.assertIn('/%s|db' % self.protocol, str(client.db.create_database)) self.assertIn('/%s|db' % self.protocol, str(client.db.db_exist)) if server_version >= 11.0: - self.assertRaises(AttributeError, getattr, - client.db, 'create') - self.assertRaises(AttributeError, getattr, - client.db, 'get_progress') + self.assertRaises(AttributeError, getattr, client.db, 'create') + self.assertRaises(AttributeError, getattr, client.db, 'get_progress') else: self.assertIn('/%s|db' % self.protocol, str(client.db.create)) self.assertIn('/%s|db' % self.protocol, str(client.db.get_progress)) - self.assertCalls(ANY, ANY, ANY, ANY) + if self.protocol == 'xmlrpc': + expected_calls = [call('server_version', ()), call('list', ())] + if server_version <= 8.0: + expected_calls += [ + call('login', ('newdb', 'usr', 'pss')), + call('execute_kw', ('newdb', 1, 'pss', 'res.users', 'context_get')) + ] + else: + # server_version, list, web_auth + expected_calls = [ANY, ANY, ANY] + if server_version <= 8.0: + # server_version, list, login, context_get + expected_calls += [ANY] + self.assertCalls(*expected_calls) self.assertOutput('') def test_service_openerp_61_to_70(self): @@ -140,12 +159,13 @@ class TestServiceJsonRpc(TestService): protocol = 'jsonrpc' def _patch_service(self): - return mock.patch('odooly.http_post', return_value={'result': 'JSON_RESULT'}).start() + return mock.patch('odooly.Client._post', return_value={'result': 'JSON_RESULT'}).start() class TestCreateClient(XmlRpcTestCase): """Test the Client class.""" server_version = '6.1' + server = f'{XmlRpcTestCase.server}/xmlrpc' startup_calls = ( call(ANY, 'db', ANY, verbose=ANY), 'db.server_version', @@ -161,12 +181,12 @@ def test_create(self): self.service.db.list.return_value = ['newdb'] self.service.common.login.return_value = 1 - client = odooly.Client('http://127.0.0.1:8069', 'newdb', 'usr', 'pss') + url_xmlrpc = f"{self.server}/xmlrpc" + client = odooly.Client(url_xmlrpc, 'newdb', 'usr', 'pss') expected_calls = self.startup_calls + ( call.common.login('newdb', 'usr', 'pss'), - call.object.execute_kw('newdb', 1, 'pss', 'res.users', 'context_get', ()), + call.object.execute_kw('newdb', 1, 'pss', 'res.users', 'context_get'), ) - url_xmlrpc = 'http://127.0.0.1:8069/xmlrpc' self.assertIsInstance(client, odooly.Client) self.assertCalls(*expected_calls) self.assertEqual( @@ -188,8 +208,7 @@ def test_create_getpass(self): ) # A: Invalid login - self.assertRaises(odooly.Error, odooly.Client, - 'http://127.0.0.1:8069', 'database', 'usr') + self.assertRaises(odooly.Error, odooly.Client, self.server, 'database', 'usr') self.assertCalls(*expected_calls) self.assertEqual(getpass.call_count, 1) @@ -197,10 +216,10 @@ def test_create_getpass(self): self.service.common.login.return_value = 17 getpass.reset_mock() expected_calls = expected_calls + ( - call.object.execute_kw('database', 17, 'password', 'res.users', 'context_get', ()), + call.object.execute_kw('database', 17, 'password', 'res.users', 'context_get'), ) - client = odooly.Client('http://127.0.0.1:8069', 'database', 'usr') + client = odooly.Client(self.server, 'database', 'usr') self.assertIsInstance(client, odooly.Client) self.assertCalls(*expected_calls) self.assertEqual(getpass.call_count, 1) @@ -208,19 +227,19 @@ def test_create_getpass(self): def test_create_with_cache(self): self.service.db.list.return_value = ['database'] self.assertFalse(odooly.Env._cache) - url_xmlrpc = 'http://127.0.0.1:8069/xmlrpc' + url_xmlrpc = f"{self.server}/xmlrpc" mock.patch.dict(odooly.Env._cache, {('auth', 'database', url_xmlrpc): {'usr': (1, 'password')}}).start() - client = odooly.Client('http://127.0.0.1:8069', 'database', 'usr') + client = odooly.Client(url_xmlrpc, 'database', 'usr') self.assertIsInstance(client, odooly.Client) self.assertCalls(*(self.startup_calls + ( - call.object.execute_kw('database', 1, 'password', 'res.users', 'context_get', ()), + call.object.execute_kw('database', 1, 'password', 'res.users', 'context_get'), ))) self.assertOutput('') def test_create_from_config(self): - env_tuple = ('http://127.0.0.1:8069', 'database', 'usr', None) + env_tuple = (self.server, 'database', 'usr', None) read_config = mock.patch('odooly.read_config', return_value=env_tuple).start() getpass = mock.patch('getpass.getpass', @@ -241,7 +260,7 @@ def test_create_from_config(self): read_config.reset_mock() getpass.reset_mock() expected_calls = expected_calls + ( - call.object.execute_kw('database', 17, 'password', 'res.users', 'context_get', ()), + call.object.execute_kw('database', 17, 'password', 'res.users', 'context_get'), ) client = odooly.Client.from_config('test') @@ -254,13 +273,13 @@ def test_create_invalid(self): # Without mock self.service.stop() - self.assertRaises(EnvironmentError, odooly.Client, 'dsadas') + self.assertRaises(EnvironmentError, odooly.Client, 'http://dsadas/jsonrpc/1') self.assertOutput('') class TestSampleSession(XmlRpcTestCase): server_version = '6.1' - server = 'http://127.0.0.1:8069' + server = f'{XmlRpcTestCase.server}/xmlrpc' database = 'database' user = 'user' password = 'passwd' @@ -321,7 +340,7 @@ def test_module_upgrade(self): class TestClientApi(XmlRpcTestCase): """Test the Client API.""" server_version = '6.1' - server = 'http://127.0.0.1:8069' + server = f'{XmlRpcTestCase.server}/xmlrpc' database = 'database' user = 'user' password = 'passwd' @@ -341,38 +360,36 @@ def test_create_database(self): create_database('abc', 'db1') create_database('xyz', 'db2', user_password='secret', lang='fr_FR') - self.assertCalls( + expected_calls = [ call.db.create_database('abc', 'db1', False, 'en_US', 'admin'), call.db.list(), - call.common.login('db1', 'admin', 'admin'), - call.object.execute_kw('db1', 1, 'admin', 'res.users', 'context_get', ()), call.db.create_database('xyz', 'db2', False, 'fr_FR', 'secret'), call.db.list(), - call.common.login('db2', 'admin', 'secret'), - call.object.execute_kw('db2', 1, 'secret', 'res.users', 'context_get', ()), - ) - self.assertOutput('') + ] + if float(self.server_version) <= 8.0: + expected_calls[2:2] = [ + call.common.login('db1', 'admin', 'admin'), + call.object.execute_kw('db1', 1, 'admin', 'res.users', 'context_get'), + ] + expected_calls[6:] = [ + call.common.login('db2', 'admin', 'secret'), + call.object.execute_kw('db2', 1, 'secret', 'res.users', 'context_get'), + ] - if float(self.server_version) < 9.0: self.assertRaises( odooly.Error, create_database, 'xyz', 'db2', user_password='secret', lang='fr_FR', login='other_login', country_code='CA', ) self.assertRaises(odooly.Error, create_database, 'xyz', 'db2', login='other_login') - self.assertRaises(odooly.Error, create_database, 'xyz', 'db2', country_code='CA') - self.assertOutput('') - return + else: # Odoo >= 9.0 + self.client.db.list.side_effect = [['db2']] + create_database('xyz', 'db2', user_password='secret', lang='fr_FR', login='other_login', country_code='CA') - # Odoo 9 - self.client.db.list.side_effect = [['db2']] - create_database('xyz', 'db2', user_password='secret', lang='fr_FR', login='other_login', country_code='CA') - - self.assertCalls( - call.db.create_database('xyz', 'db2', False, 'fr_FR', 'secret', 'other_login', 'CA'), - call.db.list(), - call.common.login('db2', 'other_login', 'secret'), - call.object.execute_kw('db2', 1, 'secret', 'res.users', 'context_get', ()), - ) + expected_calls += [ + call.db.create_database('xyz', 'db2', False, 'fr_FR', 'secret', 'other_login', 'CA'), + call.db.list(), + ] + self.assertCalls(*expected_calls) self.assertOutput('') def test_nonexistent_methods(self): @@ -540,11 +557,12 @@ def test_module_upgrade(self): self._module_upgrade('uninstall') def test_sudo(self): + ctx_lang = {'lang': 'it_IT'} self.service.object.execute_kw.side_effect = [ - False, 123, [{'id': 123, 'login': 'guest', 'password': 'xxx'}]] + False, 123, [{'id': 123, 'login': 'guest', 'password': 'xxx'}], ctx_lang] env = self.env(user='guest') - self.service.object.execute_kw.side_effect = [False, RuntimeError] + self.service.object.execute_kw.side_effect = [ctx_lang, False, RuntimeError] self.assertTrue(env.sudo().access('res.users', 'write')) self.assertFalse(env.access('res.users', 'write')) @@ -552,10 +570,14 @@ def test_sudo(self): OBJ('ir.model.access', 'check', 'res.users', 'write'), OBJ('res.users', 'search', [('login', '=', 'guest')]), OBJ('res.users', 'read', 123, ['id', 'login', 'password']), + ('object.execute_kw', self.database, 123, 'xxx', + 'res.users', 'context_get'), - OBJ('ir.model.access', 'check', 'res.users', 'write', context=None), + ('object.execute_kw', self.database, 1, 'passwd', + 'res.users', 'context_get'), + OBJ('ir.model.access', 'check', 'res.users', 'write', context=ctx_lang), ('object.execute_kw', self.database, 123, 'xxx', - 'ir.model.access', 'check', ('res.users', 'write')), + 'ir.model.access', 'check', ('res.users', 'write'), {'context': ctx_lang}), ) self.assertOutput('') diff --git a/tests/test_interact.py b/tests/test_interact.py index 2f40896..a6ed6c0 100644 --- a/tests/test_interact.py +++ b/tests/test_interact.py @@ -9,6 +9,7 @@ class TestInteract(XmlRpcTestCase): server_version = '6.1' + server = f"{XmlRpcTestCase.server}/xmlrpc" startup_calls = ( call(ANY, 'db', ANY, verbose=ANY), 'db.server_version', @@ -21,7 +22,7 @@ class TestInteract(XmlRpcTestCase): ) def setUp(self): - super(TestInteract, self).setUp() + super().setUp() # Hide readline module mock.patch.dict('sys.modules', {'readline': None}).start() mock.patch('odooly.Client._globals', None).start() @@ -31,7 +32,7 @@ def setUp(self): mock.patch('odooly.main.__defaults__', (self.interact,)).start() def test_main(self): - env_tuple = ('http://127.0.0.1:8069', 'database', 'usr', None) + env_tuple = (self.server, 'database', 'usr', None) mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start() read_config = mock.patch('odooly.read_config', return_value=env_tuple).start() @@ -54,13 +55,11 @@ def test_main(self): self.assertEqual(sys.ps2, ' ... ') expected_calls = self.startup_calls + ( ('common.login', 'database', 'usr', 'password'), - ('object.execute_kw', 'database', 17, 'password', - 'res.users', 'context_get', ()), + ('object.execute_kw', 'database', 17, 'password', 'res.users', 'context_get'), ('object.execute_kw', 'database', 17, 'password', 'ir.model.access', 'check', ('res.users', 'write')), ('common.login', 'database', 'gaspard', 'password'), - ('object.execute_kw', 'database', 51, 'password', - 'res.users', 'context_get', ()), + ('object.execute_kw', 'database', 51, 'password', 'res.users', 'context_get'), ) self.assertCalls(*expected_calls) self.assertEqual(getpass.call_count, 2) @@ -69,7 +68,7 @@ def test_main(self): outlines = self.stdout.popvalue().splitlines() self.assertSequenceEqual(outlines[-5:], [ "Logged in as 'usr'", - "", + f"", "", "Logged in as 'gaspard'", "42", @@ -77,7 +76,7 @@ def test_main(self): self.assertOutput(stderr='\x1b[A\n\n', startswith=True) def test_no_database(self): - env_tuple = ('http://127.0.0.1:8069', 'missingdb', 'usr', None) + env_tuple = (self.server, 'missingdb', 'usr', None) mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start() read_config = mock.patch('odooly.read_config', return_value=env_tuple).start() @@ -97,14 +96,14 @@ def test_no_database(self): outlines = self.stdout.popvalue().splitlines() self.assertSequenceEqual(outlines[-4:], [ "Error: Database 'missingdb' does not exist: ['database']", - "", + f"", "", "Error: Not connected", ]) self.assertOutput(stderr=ANY) def test_invalid_user_password(self): - env_tuple = ('http://127.0.0.1:8069', 'database', 'usr', 'passwd') + env_tuple = (self.server, 'database', 'usr', 'passwd') mock.patch('sys.argv', new=['odooly', '--env', 'demo']).start() mock.patch('os.environ', new={'LANG': 'fr_FR.UTF-8'}).start() mock.patch('odooly.read_config', return_value=env_tuple).start() @@ -126,8 +125,7 @@ def usr17(model, method, *params): expected_calls = self.startup_calls + ( ('common.login', 'database', 'usr', 'passwd'), - ('object.execute_kw', 'database', 17, 'passwd', - 'res.users', 'context_get', ()), + ('object.execute_kw', 'database', 17, 'passwd', 'res.users', 'context_get'), usr17('ir.model', 'search', [('model', 'like', 'res.company')]), usr17('ir.model', 'read', 42, ('model',)), diff --git a/tests/test_model.py b/tests/test_model.py index 3a9f3c7..b9d5fdc 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -6,13 +6,13 @@ class TestCase(XmlRpcTestCase): server_version = '6.1' - server = 'http://127.0.0.1:8069' + server = f"{XmlRpcTestCase.server}/xmlrpc" database = 'database' user = 'user' password = 'passwd' uid = 1 - def obj_exec(self, db_name, uid, passwd, model, method, args, kw=None): + def obj_exec(self, db_name, uid, passwd, model, method, args=None, kw=None): if method == 'search': domain = args[0] if model.startswith('ir.model'): @@ -77,6 +77,8 @@ def __getitem__(self, key): if 8888 in ids: ids[ids.index(8888)] = b'\xdan\xeecode'.decode('latin-1') return [(res_id, b'name_%s'.decode() % res_id) for res_id in ids] + if method == 'context_get': + return {'tz': 'Europe/Zurich', 'lang': 'en_US'} if method in ('create', 'copy'): single_id = ( (method == 'copy' and isinstance(args[0], int)) or @@ -86,7 +88,7 @@ def __getitem__(self, key): return [sentinel.OTHER] def setUp(self): - super(TestCase, self).setUp() + super().setUp() self.service.object.execute_kw.side_effect = self.obj_exec # preload 'foo.bar' self.env['foo.bar'] @@ -1320,14 +1322,20 @@ def test_sudo(self): records = env['foo.bar'].browse([13, 17]) rec = env['foo.bar'].browse(42) - def guest(model, method, *params): + def guest(model, method, *params, **kw): + if 'context' not in kw: + kw['context'] = {'lang': 'en_US', 'tz': 'Europe/Zurich'} + elif kw['context'] is None: + del kw['context'] + extra = (params, kw) if kw else (params,) if params else () return ('object.execute_kw', self.database, 1001, 'v_password', - model, method, params) + model, method, *extra) self.assertCalls( OBJ('ir.model.access', 'check', 'res.users', 'write'), OBJ('res.users', 'search', [('login', '=', 'guest')]), OBJ('res.users', 'read', [1001, 1002], ['id', 'login', 'password']), + guest('res.users', 'context_get', context=None), ) records.read() @@ -1340,9 +1348,11 @@ def guest(model, method, *params): self.assertCalls( guest('foo.bar', 'read', [13, 17], None), guest('foo.bar', 'fields_get'), - OBJ('foo.bar', 'read', [13, 17], None, context=None), + OBJ('res.users', 'context_get', context=None), + OBJ('foo.bar', 'read', [13, 17], None), guest('foo.bar', 'read', [42], ['message']), - OBJ('foo.bar', 'read', [42], ['name', 'message'], context=None), + OBJ('res.users', 'context_get', context=None), + OBJ('foo.bar', 'read', [42], ['name', 'message']), guest('foo.bar', 'read', [42], ['message']), guest('foo.bar', 'read', [13, 17], None), )